release-skill 0.1.9 → 0.2.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/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +2 -2
- package/.kimi-plugin/plugin.json +1 -1
- package/CHANGELOG.md +53 -0
- package/INSTALL.md +4 -4
- package/INSTALL.zh-CN.md +4 -4
- package/README.md +18 -33
- package/README.zh-CN.md +17 -23
- package/adapters/claude/.claude-plugin/marketplace.json +1 -1
- package/adapters/claude/.claude-plugin/plugin.json +1 -1
- package/adapters/claude/bin/release-skill.bundle.mjs +2740 -1844
- package/adapters/claude/schemas/.render-manifest.json +8 -8
- package/adapters/claude/schemas/approval-record.schema.json +1 -1
- package/adapters/claude/schemas/release-plan.schema.json +6 -2
- package/adapters/claude/schemas/release-project.schema.json +14 -0
- package/adapters/codex/.codex-plugin/plugin.json +2 -2
- package/adapters/codex/bin/release-skill.bundle.mjs +2740 -1844
- package/adapters/codex/schemas/.render-manifest.json +8 -8
- package/adapters/codex/schemas/approval-record.schema.json +1 -1
- package/adapters/codex/schemas/release-plan.schema.json +6 -2
- package/adapters/codex/schemas/release-project.schema.json +14 -0
- package/adapters/kimi/.kimi-plugin/plugin.json +1 -1
- package/adapters/kimi/bin/release-skill.bundle.mjs +2740 -1844
- package/adapters/kimi/schemas/.render-manifest.json +8 -8
- package/adapters/kimi/schemas/approval-record.schema.json +1 -1
- package/adapters/kimi/schemas/release-plan.schema.json +6 -2
- package/adapters/kimi/schemas/release-project.schema.json +14 -0
- package/bin/release-skill-cli.mjs +3 -0
- package/bin/release-skill.bundle.mjs +2740 -1844
- package/package.json +8 -2
- package/references/.render-manifest.json +8 -8
- package/references/01-state-machine.md +5 -5
- package/references/02-project-config.md +1 -1
- package/references/05-evidence-and-errors.md +1 -1
- package/references/06-adapter-contract.md +41 -1
- package/schemas/.render-manifest.json +8 -8
- package/schemas/approval-record.schema.json +1 -1
- package/schemas/release-plan.schema.json +6 -2
- package/schemas/release-project.schema.json +14 -0
- package/scripts/sync-public-files.mjs +462 -0
- package/src/adapters/contract.mjs +60 -0
- package/src/adapters/plugin-marketplace.mjs +289 -730
- package/src/commands/prepare.mjs +195 -182
- package/src/commands/publish.mjs +438 -122
- package/src/commands/reconcile.mjs +369 -191
- package/src/commands/verify.mjs +13 -2
- package/src/core/approval.mjs +72 -45
- package/src/core/baseline.mjs +16 -0
- package/src/core/checkpoints.mjs +143 -0
- package/src/core/evidence.mjs +30 -3
- package/src/core/hook-cache.mjs +254 -0
- package/src/core/hooks.mjs +37 -1
- package/src/core/observe-retry.mjs +223 -0
- package/src/core/plan.mjs +162 -253
- package/src/platforms/kimi.mjs +514 -0
- package/src/platforms/registry.mjs +393 -0
- package/src/producers/build-adapters.mjs +14 -22
- package/src/snapshot/frozen.mjs +29 -5
|
@@ -39,6 +39,7 @@ import {
|
|
|
39
39
|
reObservePreviousPublicBaseline,
|
|
40
40
|
} from '../core/previous-public-baseline.mjs';
|
|
41
41
|
import { createEvidenceWriter } from '../core/evidence.mjs';
|
|
42
|
+
import { CHECKPOINT_ORDER, ADAPTER_ACTION_TYPE_MAP } from '../core/checkpoints.mjs';
|
|
42
43
|
import {
|
|
43
44
|
loadRun,
|
|
44
45
|
validateRunPlanDigest,
|
|
@@ -59,42 +60,15 @@ import {
|
|
|
59
60
|
} from '../core/errors.mjs';
|
|
60
61
|
import { assertTransition, PARTIAL, PUBLISHED, BLOCKED } from '../core/state-machine.mjs';
|
|
61
62
|
import { matchObservation } from '../adapters/contract.mjs';
|
|
63
|
+
import { observeWithRetry, clampPolicyToTimeout, DEFAULT_OBSERVE_RETRY_POLICY } from '../core/observe-retry.mjs';
|
|
62
64
|
|
|
63
65
|
// ---------------------------------------------------------------------------
|
|
64
66
|
// Constants
|
|
65
67
|
// ---------------------------------------------------------------------------
|
|
66
68
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
*/
|
|
71
|
-
const CHECKPOINT_ORDER = [
|
|
72
|
-
'push-commit',
|
|
73
|
-
'push-snapshot',
|
|
74
|
-
'set-default-branch',
|
|
75
|
-
'create-tag',
|
|
76
|
-
'npm-publish',
|
|
77
|
-
'github-release',
|
|
78
|
-
'claude-marketplace-install',
|
|
79
|
-
'codex-marketplace-install',
|
|
80
|
-
'kimi-marketplace-install',
|
|
81
|
-
];
|
|
82
|
-
|
|
83
|
-
/**
|
|
84
|
-
* Map plan action type to adapter ActionType.
|
|
85
|
-
* Must match publish.mjs.
|
|
86
|
-
*/
|
|
87
|
-
const ADAPTER_ACTION_TYPE_MAP = {
|
|
88
|
-
'push-commit': 'git-push',
|
|
89
|
-
'push-snapshot': 'push-snapshot',
|
|
90
|
-
'set-default-branch': 'set-default-branch',
|
|
91
|
-
'create-tag': 'git-tag',
|
|
92
|
-
'npm-publish': 'npm-publish',
|
|
93
|
-
'github-release': 'github-release',
|
|
94
|
-
'claude-marketplace-install': 'claude-marketplace-install',
|
|
95
|
-
'codex-marketplace-install': 'codex-marketplace-install',
|
|
96
|
-
'kimi-marketplace-install': 'kimi-marketplace-install',
|
|
97
|
-
};
|
|
69
|
+
// CHECKPOINT_ORDER and ADAPTER_ACTION_TYPE_MAP live in ../core/checkpoints.mjs
|
|
70
|
+
// (single source shared with publish.mjs; T3.1 §4.7). The historical
|
|
71
|
+
// `Must match publish.mjs` double-write is removed.
|
|
98
72
|
|
|
99
73
|
/**
|
|
100
74
|
* Marketplace action types that are reconstructable isolated consumer checks,
|
|
@@ -151,6 +125,7 @@ export async function reconcileRelease(options) {
|
|
|
151
125
|
captureBaselineFn,
|
|
152
126
|
productionConfirmation,
|
|
153
127
|
observePreviousPublicBaselineFn,
|
|
128
|
+
observeRetrySleep,
|
|
154
129
|
} = options ?? {};
|
|
155
130
|
|
|
156
131
|
const clockFn = typeof clockOpt === 'function' ? clockOpt : defaultClock;
|
|
@@ -364,31 +339,62 @@ export async function reconcileRelease(options) {
|
|
|
364
339
|
|
|
365
340
|
const currentBaseline = await captureBaselineActual(root);
|
|
366
341
|
|
|
342
|
+
// planVersion fork (design: t1-2-digest-decoupling.md §4.3): for v2
|
|
343
|
+
// plans the baseline is record-layer audit data. Drift is recorded as a
|
|
344
|
+
// warning and execution continues -- artifact integrity is sealed by the
|
|
345
|
+
// frozen-artifact re-verification, never by workspace equality. v1 plans
|
|
346
|
+
// keep the BASELINE_CHANGED hard failure, byte for byte.
|
|
347
|
+
const planV2 = plan.planVersion === 2;
|
|
348
|
+
|
|
367
349
|
if (currentBaseline.gitTreeHash !== plan.baseline.gitTreeHash) {
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
350
|
+
if (planV2) {
|
|
351
|
+
await evidence.append({
|
|
352
|
+
phase: 'safety-gate',
|
|
353
|
+
gate: 'baseline-check',
|
|
354
|
+
status: 'warning',
|
|
355
|
+
severity: 'warning',
|
|
356
|
+
reason: 'planVersion 2: baseline drift is record-layer audit data; frozen-artifact re-verification remains the integrity authority',
|
|
357
|
+
planTreeHash: plan.baseline.gitTreeHash,
|
|
358
|
+
currentTreeHash: currentBaseline.gitTreeHash,
|
|
359
|
+
});
|
|
360
|
+
} else {
|
|
361
|
+
await evidence.append({
|
|
362
|
+
phase: 'safety-gate',
|
|
363
|
+
gate: 'baseline-check',
|
|
364
|
+
status: 'failed',
|
|
365
|
+
planTreeHash: plan.baseline.gitTreeHash,
|
|
366
|
+
currentTreeHash: currentBaseline.gitTreeHash,
|
|
367
|
+
});
|
|
375
368
|
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
369
|
+
throw new ReleaseError(
|
|
370
|
+
BASELINE_CHANGED,
|
|
371
|
+
`baseline has changed since plan freeze: plan=${plan.baseline.gitTreeHash}, current=${currentBaseline.gitTreeHash}`,
|
|
372
|
+
{ planTreeHash: plan.baseline.gitTreeHash, currentTreeHash: currentBaseline.gitTreeHash },
|
|
373
|
+
);
|
|
374
|
+
}
|
|
381
375
|
}
|
|
382
376
|
|
|
383
377
|
if (
|
|
384
378
|
plan.baseline.workspaceDigest &&
|
|
385
379
|
currentBaseline.workspaceDigest !== plan.baseline.workspaceDigest
|
|
386
380
|
) {
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
381
|
+
if (planV2) {
|
|
382
|
+
await evidence.append({
|
|
383
|
+
phase: 'safety-gate',
|
|
384
|
+
gate: 'baseline-check',
|
|
385
|
+
status: 'warning',
|
|
386
|
+
severity: 'warning',
|
|
387
|
+
reason: 'planVersion 2: workspace digest drift is record-layer audit data; frozen-artifact re-verification remains the integrity authority',
|
|
388
|
+
planWorkspaceDigest: plan.baseline.workspaceDigest,
|
|
389
|
+
currentWorkspaceDigest: currentBaseline.workspaceDigest,
|
|
390
|
+
});
|
|
391
|
+
} else {
|
|
392
|
+
throw new ReleaseError(
|
|
393
|
+
BASELINE_CHANGED,
|
|
394
|
+
`workspace digest has changed since plan freeze: plan=${plan.baseline.workspaceDigest}, current=${currentBaseline.workspaceDigest}`,
|
|
395
|
+
{ planWorkspaceDigest: plan.baseline.workspaceDigest, currentWorkspaceDigest: currentBaseline.workspaceDigest },
|
|
396
|
+
);
|
|
397
|
+
}
|
|
392
398
|
}
|
|
393
399
|
|
|
394
400
|
await evidence.append({
|
|
@@ -559,15 +565,21 @@ export async function reconcileRelease(options) {
|
|
|
559
565
|
// --- Phase 1: Process each plan action using source run checkpoint ---
|
|
560
566
|
//
|
|
561
567
|
// Marketplace actions are reconstructable isolated consumer checks, not
|
|
562
|
-
// permanent remote writes.
|
|
563
|
-
//
|
|
564
|
-
//
|
|
568
|
+
// permanent remote writes. Their recovery is DEFERRED to phase 4 (after
|
|
569
|
+
// the non-marketplace remote-write retry loop): github-release is the
|
|
570
|
+
// prerequisite for kimi's install URL, and a marketplace failure must
|
|
571
|
+
// never block pending remote-write retries.
|
|
565
572
|
//
|
|
566
573
|
// Non-marketplace actions use observe-based consistency checks.
|
|
567
574
|
// =======================================================================
|
|
568
575
|
const actionsToRetry = [];
|
|
576
|
+
const marketplaceRetriesToRetry = [];
|
|
569
577
|
const actionResults = new Map(); // actionId -> final status
|
|
570
|
-
|
|
578
|
+
// Remote-write retries and marketplace recovery are independent failure
|
|
579
|
+
// groups: neither group's failure blocks the other group's retries, and
|
|
580
|
+
// either failure keeps the run PARTIAL.
|
|
581
|
+
let remoteWriteRetryFailed = false;
|
|
582
|
+
let marketplaceRetryFailed = false;
|
|
571
583
|
|
|
572
584
|
for (const action of planActions) {
|
|
573
585
|
const sourceCp = sourceCpMap.get(action.id);
|
|
@@ -582,103 +594,11 @@ export async function reconcileRelease(options) {
|
|
|
582
594
|
const adapter = adapterRegistry.getAdapter(adapterActionType);
|
|
583
595
|
|
|
584
596
|
// -------------------------------------------------------------------
|
|
585
|
-
// Marketplace actions:
|
|
597
|
+
// Marketplace actions: deferred to the dedicated recovery phase that
|
|
598
|
+
// runs AFTER the non-marketplace remote-write retry loop (phase 4).
|
|
599
|
+
// A marketplace failure must never block pending remote-write retries.
|
|
586
600
|
// -------------------------------------------------------------------
|
|
587
601
|
if (MARKETPLACE_TYPES.has(action.type)) {
|
|
588
|
-
const marketplaceContext = {
|
|
589
|
-
externalWritesAuthorized: false,
|
|
590
|
-
isolatedConsumerWritesAuthorized: true,
|
|
591
|
-
plan,
|
|
592
|
-
baseline: plan.baseline,
|
|
593
|
-
root,
|
|
594
|
-
runDir,
|
|
595
|
-
};
|
|
596
|
-
|
|
597
|
-
const actionInput = {
|
|
598
|
-
actionType: adapterActionType,
|
|
599
|
-
...action.parameters,
|
|
600
|
-
};
|
|
601
|
-
|
|
602
|
-
// Preflight (always runs — validates frozen snapshot)
|
|
603
|
-
const preflightResult = await adapter.preflight(actionInput, marketplaceContext);
|
|
604
|
-
if (preflightResult.status === 'PREFLIGHT_FAILED') {
|
|
605
|
-
actionResults.set(action.id, 'failed');
|
|
606
|
-
await evidence.append({
|
|
607
|
-
phase: 'reconcile-marketplace',
|
|
608
|
-
actionId: action.id,
|
|
609
|
-
actionType: action.type,
|
|
610
|
-
decision: 'preflight-failed',
|
|
611
|
-
error: preflightResult.error,
|
|
612
|
-
});
|
|
613
|
-
throw new ReleaseError(
|
|
614
|
-
POST_PUBLISH_VERIFY_FAILED,
|
|
615
|
-
`marketplace preflight failed for action "${action.id}": ${preflightResult.error}`,
|
|
616
|
-
{ actionId: action.id },
|
|
617
|
-
);
|
|
618
|
-
}
|
|
619
|
-
|
|
620
|
-
// Execute (install to isolated consumer directory)
|
|
621
|
-
const executeResult = await adapter.execute(actionInput, marketplaceContext);
|
|
622
|
-
if (executeResult.status !== 'EXECUTED') {
|
|
623
|
-
actionResults.set(action.id, 'failed');
|
|
624
|
-
await evidence.append({
|
|
625
|
-
phase: 'reconcile-marketplace',
|
|
626
|
-
actionId: action.id,
|
|
627
|
-
actionType: action.type,
|
|
628
|
-
decision: 'execute-failed',
|
|
629
|
-
error: executeResult.error,
|
|
630
|
-
});
|
|
631
|
-
// Execute failed — if source was succeeded this is a conflict;
|
|
632
|
-
// if source was failed/pending, add to retry list
|
|
633
|
-
if (sourceCp.status === 'succeeded') {
|
|
634
|
-
throw new ReleaseError(
|
|
635
|
-
REMOTE_CONFLICT,
|
|
636
|
-
`marketplace execute failed for SUCCEEDED action "${action.id}": ${executeResult.error}`,
|
|
637
|
-
{ actionId: action.id },
|
|
638
|
-
);
|
|
639
|
-
}
|
|
640
|
-
actionsToRetry.push(action);
|
|
641
|
-
continue;
|
|
642
|
-
}
|
|
643
|
-
|
|
644
|
-
// Verify (observe + match against plan expected state)
|
|
645
|
-
const verifyResult = await adapter.verify(
|
|
646
|
-
{ ...actionInput, expected: action.expected },
|
|
647
|
-
marketplaceContext,
|
|
648
|
-
);
|
|
649
|
-
|
|
650
|
-
if (verifyResult.status === 'VERIFIED') {
|
|
651
|
-
// Consistent: source succeeded => skipped, source failed => succeeded (recovered)
|
|
652
|
-
const resultStatus = sourceCp.status === 'succeeded' ? 'skipped' : 'succeeded';
|
|
653
|
-
actionResults.set(action.id, resultStatus);
|
|
654
|
-
await evidence.append({
|
|
655
|
-
phase: 'reconcile-marketplace',
|
|
656
|
-
actionId: action.id,
|
|
657
|
-
actionType: action.type,
|
|
658
|
-
decision: sourceCp.status === 'succeeded' ? 'skip-source-succeeded-consistent' : 'recovered',
|
|
659
|
-
sourceStatus: sourceCp.status,
|
|
660
|
-
});
|
|
661
|
-
} else {
|
|
662
|
-
// Verify mismatch
|
|
663
|
-
await evidence.append({
|
|
664
|
-
phase: 'reconcile-marketplace',
|
|
665
|
-
actionId: action.id,
|
|
666
|
-
actionType: action.type,
|
|
667
|
-
decision: 'verify-mismatch',
|
|
668
|
-
error: verifyResult.error,
|
|
669
|
-
});
|
|
670
|
-
if (sourceCp.status === 'succeeded') {
|
|
671
|
-
throw new ReleaseError(
|
|
672
|
-
REMOTE_CONFLICT,
|
|
673
|
-
`marketplace verify mismatch for SUCCEEDED action "${action.id}": ${verifyResult.error}`,
|
|
674
|
-
{ actionId: action.id },
|
|
675
|
-
);
|
|
676
|
-
}
|
|
677
|
-
// Source was failed/pending and verify mismatched after retry => BLOCKED
|
|
678
|
-
actionResults.set(action.id, 'failed');
|
|
679
|
-
retryFailed = true;
|
|
680
|
-
// Don't add to retry — this is a verify mismatch, not a missing state
|
|
681
|
-
}
|
|
682
602
|
continue;
|
|
683
603
|
}
|
|
684
604
|
|
|
@@ -805,11 +725,30 @@ export async function reconcileRelease(options) {
|
|
|
805
725
|
continue;
|
|
806
726
|
}
|
|
807
727
|
|
|
808
|
-
// FAILED or PENDING: observe remote state
|
|
809
|
-
const
|
|
810
|
-
|
|
728
|
+
// FAILED or PENDING: observe remote state (PROPAGATING retry, T1.1)
|
|
729
|
+
const observeInput = { actionType: adapterActionType, ...action.parameters };
|
|
730
|
+
const observePolicy = MARKETPLACE_TYPES.has(action.type)
|
|
731
|
+
? clampPolicyToTimeout(DEFAULT_OBSERVE_RETRY_POLICY, action.parameters?.timeoutMs)
|
|
732
|
+
: DEFAULT_OBSERVE_RETRY_POLICY;
|
|
733
|
+
const observeRetry = await observeWithRetry({
|
|
734
|
+
observe: (act, ctx) => adapter.observe(act, ctx),
|
|
735
|
+
action: observeInput,
|
|
811
736
|
context,
|
|
812
|
-
|
|
737
|
+
policy: observePolicy,
|
|
738
|
+
sleep: observeRetrySleep,
|
|
739
|
+
onAttempt: (info) => evidence.append({
|
|
740
|
+
phase: 'reconcile-observe-retry',
|
|
741
|
+
actionId: action.id,
|
|
742
|
+
actionType: action.type,
|
|
743
|
+
attempt: info.attempt,
|
|
744
|
+
maxAttempts: info.maxAttempts,
|
|
745
|
+
missing: info.missing,
|
|
746
|
+
delayMs: info.delayMs,
|
|
747
|
+
status: info.missing ? 'propagating' : 'resolved',
|
|
748
|
+
error: info.error ?? null,
|
|
749
|
+
}),
|
|
750
|
+
});
|
|
751
|
+
const observeResult = observeRetry.result;
|
|
813
752
|
|
|
814
753
|
if (
|
|
815
754
|
!observeResult.observation ||
|
|
@@ -941,12 +880,12 @@ export async function reconcileRelease(options) {
|
|
|
941
880
|
|
|
942
881
|
// --- Phase 2: Validate approval and global preflight before retrying ---
|
|
943
882
|
//
|
|
944
|
-
// Marketplace
|
|
945
|
-
// productionConfirmation;
|
|
946
|
-
// permanent remote writes. Only non-marketplace
|
|
883
|
+
// Marketplace recovery is deferred to phase 4 and does NOT require
|
|
884
|
+
// approval or productionConfirmation; marketplace actions are isolated
|
|
885
|
+
// consumer checks, not permanent remote writes. Only non-marketplace
|
|
886
|
+
// retries require approval.
|
|
947
887
|
// =======================================================================
|
|
948
|
-
//
|
|
949
|
-
const marketplaceRetries = actionsToRetry.filter((a) => MARKETPLACE_TYPES.has(a.type));
|
|
888
|
+
// Phase 1 only collects non-marketplace retry candidates.
|
|
950
889
|
const nonMarketplaceRetries = actionsToRetry.filter((a) => !MARKETPLACE_TYPES.has(a.type));
|
|
951
890
|
|
|
952
891
|
if (nonMarketplaceRetries.length > 0) {
|
|
@@ -997,11 +936,12 @@ export async function reconcileRelease(options) {
|
|
|
997
936
|
);
|
|
998
937
|
|
|
999
938
|
if (preflightResult.status === 'PREFLIGHT_FAILED') {
|
|
1000
|
-
// Mark all retry actions as failed, stop
|
|
939
|
+
// Mark all retry actions as failed, stop. This only poisons the
|
|
940
|
+
// remote-write group; marketplace recovery (phase 4) still runs.
|
|
1001
941
|
for (const retryAction of actionsToRetry) {
|
|
1002
942
|
actionResults.set(retryAction.id, 'failed');
|
|
1003
943
|
}
|
|
1004
|
-
|
|
944
|
+
remoteWriteRetryFailed = true;
|
|
1005
945
|
|
|
1006
946
|
await evidence.append({
|
|
1007
947
|
phase: 'reconcile-preflight',
|
|
@@ -1013,7 +953,7 @@ export async function reconcileRelease(options) {
|
|
|
1013
953
|
}
|
|
1014
954
|
}
|
|
1015
955
|
|
|
1016
|
-
if (!
|
|
956
|
+
if (!remoteWriteRetryFailed) {
|
|
1017
957
|
await evidence.append({ phase: 'reconcile-preflight', status: 'passed' });
|
|
1018
958
|
}
|
|
1019
959
|
}
|
|
@@ -1061,13 +1001,16 @@ export async function reconcileRelease(options) {
|
|
|
1061
1001
|
);
|
|
1062
1002
|
return latestRetryState;
|
|
1063
1003
|
};
|
|
1064
|
-
if (!
|
|
1004
|
+
if (!remoteWriteRetryFailed && actionsToRetry.length > 0) {
|
|
1065
1005
|
await persistRetryState('PUBLISHING');
|
|
1066
1006
|
}
|
|
1067
1007
|
|
|
1068
|
-
//
|
|
1069
|
-
|
|
1070
|
-
|
|
1008
|
+
// Execute one homogeneous retry group in plan order. The first failure
|
|
1009
|
+
// stops the group (publish semantics) and marks only that group's own
|
|
1010
|
+
// failure flag; a failure in one group never blocks the other group's
|
|
1011
|
+
// retries.
|
|
1012
|
+
const executeRetryGroup = async (groupActions, markGroupFailed) => {
|
|
1013
|
+
for (const action of groupActions) {
|
|
1071
1014
|
const adapterActionType = ADAPTER_ACTION_TYPE_MAP[action.type];
|
|
1072
1015
|
const adapter = adapterRegistry.getAdapter(adapterActionType);
|
|
1073
1016
|
|
|
@@ -1103,15 +1046,42 @@ export async function reconcileRelease(options) {
|
|
|
1103
1046
|
if (executeResult.status === 'EXECUTED') {
|
|
1104
1047
|
// Must verify after execute to check remote/install state
|
|
1105
1048
|
if (MARKETPLACE_TYPES.has(action.type)) {
|
|
1106
|
-
// Marketplace: verify via adapter.verify (observe + matchObservation)
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1049
|
+
// Marketplace: verify via adapter.verify (observe + matchObservation).
|
|
1050
|
+
// Retry the read-only verify while remote state is information-
|
|
1051
|
+
// insufficient (PROPAGATING, T1.1); a present mismatch is a
|
|
1052
|
+
// real conflict and is never retried.
|
|
1053
|
+
const verifyInput = {
|
|
1054
|
+
actionType: adapterActionType,
|
|
1055
|
+
...action.parameters,
|
|
1056
|
+
expected: action.expected,
|
|
1057
|
+
};
|
|
1058
|
+
const verifyPolicy = clampPolicyToTimeout(
|
|
1059
|
+
DEFAULT_OBSERVE_RETRY_POLICY,
|
|
1060
|
+
action.parameters?.timeoutMs,
|
|
1110
1061
|
);
|
|
1062
|
+
const verifyRetry = await observeWithRetry({
|
|
1063
|
+
observe: (act, ctx) => adapter.verify(act, ctx),
|
|
1064
|
+
action: verifyInput,
|
|
1065
|
+
context: retryCtx,
|
|
1066
|
+
policy: verifyPolicy,
|
|
1067
|
+
sleep: observeRetrySleep,
|
|
1068
|
+
onAttempt: (info) => evidence.append({
|
|
1069
|
+
phase: 'reconcile-retry-verify',
|
|
1070
|
+
actionId: action.id,
|
|
1071
|
+
actionType: action.type,
|
|
1072
|
+
attempt: info.attempt,
|
|
1073
|
+
maxAttempts: info.maxAttempts,
|
|
1074
|
+
missing: info.missing,
|
|
1075
|
+
delayMs: info.delayMs,
|
|
1076
|
+
status: info.missing ? 'propagating' : 'resolved',
|
|
1077
|
+
error: info.error ?? null,
|
|
1078
|
+
}),
|
|
1079
|
+
});
|
|
1080
|
+
const verifyResult = verifyRetry.result;
|
|
1111
1081
|
|
|
1112
1082
|
if (verifyResult.status !== 'VERIFIED') {
|
|
1113
1083
|
actionResults.set(action.id, 'failed');
|
|
1114
|
-
|
|
1084
|
+
markGroupFailed();
|
|
1115
1085
|
await evidence.append({
|
|
1116
1086
|
phase: 'reconcile-retry',
|
|
1117
1087
|
actionId: action.id,
|
|
@@ -1123,22 +1093,43 @@ export async function reconcileRelease(options) {
|
|
|
1123
1093
|
break;
|
|
1124
1094
|
}
|
|
1125
1095
|
} else {
|
|
1126
|
-
// Non-marketplace: observe after execute to verify remote state
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1096
|
+
// Non-marketplace: observe after execute to verify remote state.
|
|
1097
|
+
// execute succeeded, so a missing/uncertain observe may be a
|
|
1098
|
+
// transient propagation delay — retry with bounded backoff
|
|
1099
|
+
// (PROPAGATING, T1.1). A present mismatch is a real
|
|
1100
|
+
// conflict and is never retried.
|
|
1101
|
+
const observeInput = {
|
|
1102
|
+
actionType: adapterActionType,
|
|
1103
|
+
...action.parameters,
|
|
1104
|
+
expected: action.expected,
|
|
1105
|
+
};
|
|
1106
|
+
const observePolicy = clampPolicyToTimeout(
|
|
1107
|
+
DEFAULT_OBSERVE_RETRY_POLICY,
|
|
1108
|
+
action.parameters?.timeoutMs,
|
|
1109
|
+
);
|
|
1110
|
+
const observeRetry = await observeWithRetry({
|
|
1111
|
+
observe: (act, ctx) => adapter.observe(act, ctx),
|
|
1112
|
+
action: observeInput,
|
|
1113
|
+
context: retryCtx,
|
|
1114
|
+
policy: observePolicy,
|
|
1115
|
+
sleep: observeRetrySleep,
|
|
1116
|
+
onAttempt: (info) => evidence.append({
|
|
1117
|
+
phase: 'reconcile-retry-observe',
|
|
1118
|
+
actionId: action.id,
|
|
1119
|
+
actionType: action.type,
|
|
1120
|
+
attempt: info.attempt,
|
|
1121
|
+
maxAttempts: info.maxAttempts,
|
|
1122
|
+
missing: info.missing,
|
|
1123
|
+
delayMs: info.delayMs,
|
|
1124
|
+
status: info.missing ? 'propagating' : 'resolved',
|
|
1125
|
+
error: info.error ?? null,
|
|
1126
|
+
}),
|
|
1127
|
+
});
|
|
1128
|
+
const observation = observeRetry.result?.observation ?? null;
|
|
1129
|
+
const observeError = observeRetry.result?.error ?? null;
|
|
1139
1130
|
if (!observation || (observeError && Object.keys(observation).length === 0)) {
|
|
1140
1131
|
actionResults.set(action.id, 'uncertain');
|
|
1141
|
-
|
|
1132
|
+
markGroupFailed();
|
|
1142
1133
|
await evidence.append({
|
|
1143
1134
|
phase: 'reconcile-retry',
|
|
1144
1135
|
actionId: action.id,
|
|
@@ -1155,7 +1146,7 @@ export async function reconcileRelease(options) {
|
|
|
1155
1146
|
const { matches } = matchObservation(action.expected, observation);
|
|
1156
1147
|
if (!matches) {
|
|
1157
1148
|
actionResults.set(action.id, 'failed');
|
|
1158
|
-
|
|
1149
|
+
markGroupFailed();
|
|
1159
1150
|
await evidence.append({
|
|
1160
1151
|
phase: 'reconcile-retry',
|
|
1161
1152
|
actionId: action.id,
|
|
@@ -1168,7 +1159,7 @@ export async function reconcileRelease(options) {
|
|
|
1168
1159
|
}
|
|
1169
1160
|
} else if (observation && observation.mismatched) {
|
|
1170
1161
|
actionResults.set(action.id, 'failed');
|
|
1171
|
-
|
|
1162
|
+
markGroupFailed();
|
|
1172
1163
|
await evidence.append({
|
|
1173
1164
|
phase: 'reconcile-retry',
|
|
1174
1165
|
actionId: action.id,
|
|
@@ -1227,7 +1218,7 @@ export async function reconcileRelease(options) {
|
|
|
1227
1218
|
} else {
|
|
1228
1219
|
actionResults.set(action.id, 'failed');
|
|
1229
1220
|
}
|
|
1230
|
-
|
|
1221
|
+
markGroupFailed();
|
|
1231
1222
|
|
|
1232
1223
|
await evidence.append({
|
|
1233
1224
|
phase: 'reconcile-retry',
|
|
@@ -1240,12 +1231,190 @@ export async function reconcileRelease(options) {
|
|
|
1240
1231
|
break;
|
|
1241
1232
|
}
|
|
1242
1233
|
}
|
|
1234
|
+
};
|
|
1235
|
+
|
|
1236
|
+
// --- Phase 3: Execute non-marketplace remote-write retries ---
|
|
1237
|
+
//
|
|
1238
|
+
// Runs BEFORE marketplace recovery: github-release is the prerequisite
|
|
1239
|
+
// for kimi's install URL. Gated ONLY on the remote-write group's own
|
|
1240
|
+
// failure flag — a failing marketplace recovery must never block these
|
|
1241
|
+
// retries.
|
|
1242
|
+
if (!remoteWriteRetryFailed && actionsToRetry.length > 0) {
|
|
1243
|
+
await executeRetryGroup(actionsToRetry, () => { remoteWriteRetryFailed = true; });
|
|
1244
|
+
}
|
|
1245
|
+
|
|
1246
|
+
// --- Phase 4: Marketplace recovery (isolated consumer checks) ---
|
|
1247
|
+
//
|
|
1248
|
+
// Deferred until after the remote-write retry loop. Marketplace actions
|
|
1249
|
+
// are reconstructable isolated consumer checks, not permanent remote
|
|
1250
|
+
// writes: each gets a fresh preflight -> execute -> verify cycle in an
|
|
1251
|
+
// isolated directory, using isolatedConsumerWritesAuthorized instead of
|
|
1252
|
+
// externalWritesAuthorized. A failed remote-write retry must not
|
|
1253
|
+
// fabricate or suppress marketplace outcomes, and a marketplace failure
|
|
1254
|
+
// never blocks the remote-write group above.
|
|
1255
|
+
for (const action of planActions) {
|
|
1256
|
+
if (!MARKETPLACE_TYPES.has(action.type)) continue;
|
|
1257
|
+
const sourceCp = sourceCpMap.get(action.id);
|
|
1258
|
+
const adapterActionType = ADAPTER_ACTION_TYPE_MAP[action.type];
|
|
1259
|
+
const adapter = adapterRegistry.getAdapter(adapterActionType);
|
|
1260
|
+
|
|
1261
|
+
const marketplaceContext = {
|
|
1262
|
+
externalWritesAuthorized: false,
|
|
1263
|
+
isolatedConsumerWritesAuthorized: true,
|
|
1264
|
+
plan,
|
|
1265
|
+
baseline: plan.baseline,
|
|
1266
|
+
root,
|
|
1267
|
+
runDir,
|
|
1268
|
+
};
|
|
1269
|
+
|
|
1270
|
+
const actionInput = {
|
|
1271
|
+
actionType: adapterActionType,
|
|
1272
|
+
...action.parameters,
|
|
1273
|
+
};
|
|
1274
|
+
|
|
1275
|
+
// Preflight (always runs — validates frozen snapshot)
|
|
1276
|
+
const preflightResult = await adapter.preflight(actionInput, marketplaceContext);
|
|
1277
|
+
if (preflightResult.status === 'PREFLIGHT_FAILED') {
|
|
1278
|
+
actionResults.set(action.id, 'failed');
|
|
1279
|
+
await evidence.append({
|
|
1280
|
+
phase: 'reconcile-marketplace',
|
|
1281
|
+
actionId: action.id,
|
|
1282
|
+
actionType: action.type,
|
|
1283
|
+
decision: 'preflight-failed',
|
|
1284
|
+
error: preflightResult.error,
|
|
1285
|
+
});
|
|
1286
|
+
throw new ReleaseError(
|
|
1287
|
+
POST_PUBLISH_VERIFY_FAILED,
|
|
1288
|
+
`marketplace preflight failed for action "${action.id}": ${preflightResult.error}`,
|
|
1289
|
+
{ actionId: action.id },
|
|
1290
|
+
);
|
|
1291
|
+
}
|
|
1292
|
+
|
|
1293
|
+
// Execute (install to isolated consumer directory)
|
|
1294
|
+
const executeResult = await adapter.execute(actionInput, marketplaceContext);
|
|
1295
|
+
if (executeResult.status !== 'EXECUTED') {
|
|
1296
|
+
actionResults.set(action.id, 'failed');
|
|
1297
|
+
await evidence.append({
|
|
1298
|
+
phase: 'reconcile-marketplace',
|
|
1299
|
+
actionId: action.id,
|
|
1300
|
+
actionType: action.type,
|
|
1301
|
+
decision: 'execute-failed',
|
|
1302
|
+
error: executeResult.error,
|
|
1303
|
+
});
|
|
1304
|
+
// Execute failed — if source was succeeded this is a conflict;
|
|
1305
|
+
// if source was failed/pending, add to the marketplace retry list
|
|
1306
|
+
if (sourceCp.status === 'succeeded') {
|
|
1307
|
+
throw new ReleaseError(
|
|
1308
|
+
REMOTE_CONFLICT,
|
|
1309
|
+
`marketplace execute failed for SUCCEEDED action "${action.id}": ${executeResult.error}`,
|
|
1310
|
+
{ actionId: action.id },
|
|
1311
|
+
);
|
|
1312
|
+
}
|
|
1313
|
+
marketplaceRetriesToRetry.push(action);
|
|
1314
|
+
continue;
|
|
1315
|
+
}
|
|
1316
|
+
|
|
1317
|
+
// Verify (observe + match against plan expected state).
|
|
1318
|
+
// Retry the read-only verify while remote state is information-
|
|
1319
|
+
// insufficient (PROPAGATING, T1.1); a present mismatch is a
|
|
1320
|
+
// real conflict and is never retried.
|
|
1321
|
+
const verifyPolicy = clampPolicyToTimeout(
|
|
1322
|
+
DEFAULT_OBSERVE_RETRY_POLICY,
|
|
1323
|
+
action.parameters?.timeoutMs,
|
|
1324
|
+
);
|
|
1325
|
+
const verifyRetry = await observeWithRetry({
|
|
1326
|
+
observe: (act, ctx) => adapter.verify(act, ctx),
|
|
1327
|
+
action: { ...actionInput, expected: action.expected },
|
|
1328
|
+
context: marketplaceContext,
|
|
1329
|
+
policy: verifyPolicy,
|
|
1330
|
+
sleep: observeRetrySleep,
|
|
1331
|
+
onAttempt: (info) => evidence.append({
|
|
1332
|
+
phase: 'reconcile-marketplace-verify',
|
|
1333
|
+
actionId: action.id,
|
|
1334
|
+
actionType: action.type,
|
|
1335
|
+
attempt: info.attempt,
|
|
1336
|
+
maxAttempts: info.maxAttempts,
|
|
1337
|
+
missing: info.missing,
|
|
1338
|
+
delayMs: info.delayMs,
|
|
1339
|
+
status: info.missing ? 'propagating' : 'resolved',
|
|
1340
|
+
error: info.error ?? null,
|
|
1341
|
+
}),
|
|
1342
|
+
});
|
|
1343
|
+
const verifyResult = verifyRetry.result;
|
|
1344
|
+
|
|
1345
|
+
if (verifyResult.status === 'VERIFIED') {
|
|
1346
|
+
// Consistent: source succeeded => skipped, source failed => succeeded (recovered)
|
|
1347
|
+
const resultStatus = sourceCp.status === 'succeeded' ? 'skipped' : 'succeeded';
|
|
1348
|
+
actionResults.set(action.id, resultStatus);
|
|
1349
|
+
await evidence.append({
|
|
1350
|
+
phase: 'reconcile-marketplace',
|
|
1351
|
+
actionId: action.id,
|
|
1352
|
+
actionType: action.type,
|
|
1353
|
+
decision: sourceCp.status === 'succeeded' ? 'skip-source-succeeded-consistent' : 'recovered',
|
|
1354
|
+
sourceStatus: sourceCp.status,
|
|
1355
|
+
});
|
|
1356
|
+
} else {
|
|
1357
|
+
// Verify mismatch
|
|
1358
|
+
await evidence.append({
|
|
1359
|
+
phase: 'reconcile-marketplace',
|
|
1360
|
+
actionId: action.id,
|
|
1361
|
+
actionType: action.type,
|
|
1362
|
+
decision: 'verify-mismatch',
|
|
1363
|
+
error: verifyResult.error,
|
|
1364
|
+
});
|
|
1365
|
+
if (sourceCp.status === 'succeeded') {
|
|
1366
|
+
throw new ReleaseError(
|
|
1367
|
+
REMOTE_CONFLICT,
|
|
1368
|
+
`marketplace verify mismatch for SUCCEEDED action "${action.id}": ${verifyResult.error}`,
|
|
1369
|
+
{ actionId: action.id },
|
|
1370
|
+
);
|
|
1371
|
+
}
|
|
1372
|
+
// Source was failed/pending and verify mismatched after recovery => failed.
|
|
1373
|
+
// Poisons ONLY the marketplace group; remote-write retries already ran.
|
|
1374
|
+
actionResults.set(action.id, 'failed');
|
|
1375
|
+
marketplaceRetryFailed = true;
|
|
1376
|
+
// Don't add to retry — this is a verify mismatch, not a missing state
|
|
1377
|
+
}
|
|
1378
|
+
}
|
|
1379
|
+
|
|
1380
|
+
// Marketplace retry candidates (isolated execute failed above) are
|
|
1381
|
+
// preflighted, then retried once through the shared journaled retry
|
|
1382
|
+
// loop. Gated ONLY on the marketplace group's own failure flag.
|
|
1383
|
+
if (!marketplaceRetryFailed && marketplaceRetriesToRetry.length > 0) {
|
|
1384
|
+
for (const action of marketplaceRetriesToRetry) {
|
|
1385
|
+
const adapterActionType = ADAPTER_ACTION_TYPE_MAP[action.type];
|
|
1386
|
+
const adapter = adapterRegistry.getAdapter(adapterActionType);
|
|
1387
|
+
const preflightResult = await adapter.preflight(
|
|
1388
|
+
{ actionType: adapterActionType, ...action.parameters },
|
|
1389
|
+
{ ...context, externalWritesAuthorized: false, isolatedConsumerWritesAuthorized: true },
|
|
1390
|
+
);
|
|
1391
|
+
if (preflightResult.status === 'PREFLIGHT_FAILED') {
|
|
1392
|
+
// Mark all marketplace retry actions as failed, stop
|
|
1393
|
+
for (const retryAction of marketplaceRetriesToRetry) {
|
|
1394
|
+
actionResults.set(retryAction.id, 'failed');
|
|
1395
|
+
}
|
|
1396
|
+
marketplaceRetryFailed = true;
|
|
1397
|
+
await evidence.append({
|
|
1398
|
+
phase: 'reconcile-preflight',
|
|
1399
|
+
status: 'failed',
|
|
1400
|
+
actionId: action.id,
|
|
1401
|
+
error: preflightResult.error,
|
|
1402
|
+
});
|
|
1403
|
+
break;
|
|
1404
|
+
}
|
|
1405
|
+
}
|
|
1406
|
+
}
|
|
1407
|
+
if (!marketplaceRetryFailed && marketplaceRetriesToRetry.length > 0) {
|
|
1408
|
+
if (retryStateSequence < 0) {
|
|
1409
|
+
await persistRetryState('PUBLISHING');
|
|
1410
|
+
}
|
|
1411
|
+
await executeRetryGroup(marketplaceRetriesToRetry, () => { marketplaceRetryFailed = true; });
|
|
1243
1412
|
}
|
|
1244
1413
|
|
|
1245
|
-
// Mark remaining retry actions as pending if
|
|
1246
|
-
|
|
1414
|
+
// Mark remaining retry actions as pending if a group stopped early
|
|
1415
|
+
const markPendingAfterFailure = (groupActions) => {
|
|
1247
1416
|
let foundFailed = false;
|
|
1248
|
-
for (const action of
|
|
1417
|
+
for (const action of groupActions) {
|
|
1249
1418
|
if (['failed', 'uncertain'].includes(actionResults.get(action.id))) {
|
|
1250
1419
|
foundFailed = true;
|
|
1251
1420
|
continue;
|
|
@@ -1254,12 +1423,18 @@ export async function reconcileRelease(options) {
|
|
|
1254
1423
|
actionResults.set(action.id, 'pending');
|
|
1255
1424
|
}
|
|
1256
1425
|
}
|
|
1426
|
+
};
|
|
1427
|
+
if (remoteWriteRetryFailed) {
|
|
1428
|
+
markPendingAfterFailure(actionsToRetry);
|
|
1429
|
+
}
|
|
1430
|
+
if (marketplaceRetryFailed) {
|
|
1431
|
+
markPendingAfterFailure(marketplaceRetriesToRetry);
|
|
1257
1432
|
}
|
|
1258
1433
|
|
|
1259
1434
|
// Final branch/default-branch consistency closes late drift after a retry
|
|
1260
1435
|
// or after the first observation pass. The set-default action expectation
|
|
1261
1436
|
// includes both the branch name and its exact planned commit.
|
|
1262
|
-
if (!
|
|
1437
|
+
if (!remoteWriteRetryFailed && !marketplaceRetryFailed && planActions.every((action) => ['succeeded', 'skipped'].includes(actionResults.get(action.id)))) {
|
|
1263
1438
|
await evidence.append({ phase: 'safety-gate', gate: 'final-branch-consistency', status: 'started' });
|
|
1264
1439
|
for (const action of planActions) {
|
|
1265
1440
|
if (!['push-snapshot', 'set-default-branch'].includes(action.type)) continue;
|
|
@@ -1279,7 +1454,7 @@ export async function reconcileRelease(options) {
|
|
|
1279
1454
|
const comparison = observable ? matchObservation(action.expected ?? {}, observation) : { matches: false, mismatches: [] };
|
|
1280
1455
|
if (!comparison.matches) {
|
|
1281
1456
|
actionResults.set(action.id, observable ? 'failed' : 'uncertain');
|
|
1282
|
-
|
|
1457
|
+
remoteWriteRetryFailed = true;
|
|
1283
1458
|
await evidence.append({
|
|
1284
1459
|
phase: 'safety-gate',
|
|
1285
1460
|
gate: 'final-branch-consistency',
|
|
@@ -1290,7 +1465,7 @@ export async function reconcileRelease(options) {
|
|
|
1290
1465
|
break;
|
|
1291
1466
|
}
|
|
1292
1467
|
}
|
|
1293
|
-
if (!
|
|
1468
|
+
if (!remoteWriteRetryFailed) {
|
|
1294
1469
|
await evidence.append({ phase: 'safety-gate', gate: 'final-branch-consistency', status: 'passed' });
|
|
1295
1470
|
}
|
|
1296
1471
|
}
|
|
@@ -1307,6 +1482,9 @@ export async function reconcileRelease(options) {
|
|
|
1307
1482
|
|
|
1308
1483
|
const effectiveFromStatus = PARTIAL;
|
|
1309
1484
|
|
|
1485
|
+
// A failure in either independent retry group keeps the run PARTIAL.
|
|
1486
|
+
const retryFailed = remoteWriteRetryFailed || marketplaceRetryFailed;
|
|
1487
|
+
|
|
1310
1488
|
// Map reconcile outcome to state machine target
|
|
1311
1489
|
let overallStatus;
|
|
1312
1490
|
if (allSucceeded && !retryFailed) {
|