@planu/cli 5.3.2 → 5.3.3
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/CHANGELOG.md +17 -0
- package/dist/config/runtime-policy.json +9 -0
- package/dist/engine/autopilot/complete-loop.js +4 -1
- package/dist/engine/execution/outbox-worker.d.ts +8 -0
- package/dist/engine/execution/outbox-worker.js +102 -31
- package/dist/engine/planu-core.darwin-arm64.node.manifest.json +7 -7
- package/dist/engine/planu-core.darwin-arm64.node.sbom.json +4 -4
- package/dist/engine/planu-core.darwin-x64.node.manifest.json +7 -7
- package/dist/engine/planu-core.darwin-x64.node.sbom.json +4 -4
- package/dist/engine/planu-core.linux-arm64-gnu.node.manifest.json +7 -7
- package/dist/engine/planu-core.linux-arm64-gnu.node.sbom.json +4 -4
- package/dist/engine/planu-core.linux-arm64-musl.node.manifest.json +7 -7
- package/dist/engine/planu-core.linux-arm64-musl.node.sbom.json +4 -4
- package/dist/engine/planu-core.linux-x64-gnu.node.manifest.json +7 -7
- package/dist/engine/planu-core.linux-x64-gnu.node.sbom.json +4 -4
- package/dist/engine/planu-core.linux-x64-musl.node.manifest.json +7 -7
- package/dist/engine/planu-core.linux-x64-musl.node.sbom.json +4 -4
- package/dist/engine/planu-core.win32-arm64-msvc.node.manifest.json +7 -7
- package/dist/engine/planu-core.win32-arm64-msvc.node.sbom.json +4 -4
- package/dist/engine/planu-core.win32-x64-msvc.node.manifest.json +7 -7
- package/dist/engine/planu-core.win32-x64-msvc.node.sbom.json +4 -4
- package/dist/engine/runtime-policy.js +14 -0
- package/dist/index.js +3 -1
- package/dist/storage/runtime-db.d.ts +23 -1
- package/dist/storage/runtime-db.js +79 -9
- package/dist/tools/create-spec/post-creation.js +7 -2
- package/dist/tools/create-spec/spec-created-outbox-consumer.d.ts +2 -0
- package/dist/tools/create-spec/spec-created-outbox-consumer.js +94 -6
- package/dist/tools/create-spec-helpers.d.ts +2 -2
- package/dist/tools/create-spec-helpers.js +2 -2
- package/dist/tools/git/branch-ops.d.ts +7 -3
- package/dist/tools/git/branch-ops.js +68 -18
- package/dist/tools/update-status-actions.js +4 -2
- package/dist/types/git.d.ts +4 -0
- package/dist/types/outbox-worker.d.ts +2 -0
- package/dist/types/runtime-policy.d.ts +9 -0
- package/package.json +9 -9
- package/planu-native.json +1 -1
- package/planu-plugin.json +1 -1
|
@@ -4,14 +4,14 @@
|
|
|
4
4
|
"version": 1,
|
|
5
5
|
"metadata": {
|
|
6
6
|
"component": {
|
|
7
|
-
"bom-ref": "pkg:cargo/planu-core@5.3.
|
|
7
|
+
"bom-ref": "pkg:cargo/planu-core@5.3.3",
|
|
8
8
|
"type": "library",
|
|
9
9
|
"name": "planu-core",
|
|
10
|
-
"version": "5.3.
|
|
10
|
+
"version": "5.3.3",
|
|
11
11
|
"hashes": [
|
|
12
12
|
{
|
|
13
13
|
"alg": "SHA-256",
|
|
14
|
-
"content": "
|
|
14
|
+
"content": "0e4804010d87a4089e8fd59af5d0b61ba6a572ff20f9f55b1caa904af861f030"
|
|
15
15
|
}
|
|
16
16
|
]
|
|
17
17
|
}
|
|
@@ -1106,7 +1106,7 @@
|
|
|
1106
1106
|
"dependsOn": []
|
|
1107
1107
|
},
|
|
1108
1108
|
{
|
|
1109
|
-
"ref": "pkg:cargo/planu-core@5.3.
|
|
1109
|
+
"ref": "pkg:cargo/planu-core@5.3.3",
|
|
1110
1110
|
"dependsOn": [
|
|
1111
1111
|
"pkg:cargo/core-foundation@0.10.1",
|
|
1112
1112
|
"pkg:cargo/hmac@0.12.1",
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schemaVersion": 2,
|
|
3
|
-
"cliVersion": "5.3.
|
|
4
|
-
"engineVersion": "5.3.
|
|
5
|
-
"buildId": "1-5.3.
|
|
3
|
+
"cliVersion": "5.3.3",
|
|
4
|
+
"engineVersion": "5.3.3",
|
|
5
|
+
"buildId": "1-5.3.3-aarch64-pc-windows-msvc",
|
|
6
6
|
"protocolAbi": 1,
|
|
7
7
|
"capabilityAbi": 2,
|
|
8
8
|
"nodeApiAbi": 4,
|
|
@@ -16,15 +16,15 @@
|
|
|
16
16
|
"platform": "win32",
|
|
17
17
|
"arch": "arm64",
|
|
18
18
|
"libc": null,
|
|
19
|
-
"artifactSha256": "
|
|
19
|
+
"artifactSha256": "5f4ce40b9f76cfaa1fdc25ba8ce765cc3fd7fc0b38bbe10ce8a5b3ba5b0dcc1a",
|
|
20
20
|
"sbom": {
|
|
21
21
|
"format": "CycloneDX-1.5",
|
|
22
22
|
"path": "planu-core.win32-arm64-msvc.node.sbom.json",
|
|
23
|
-
"sha256": "
|
|
23
|
+
"sha256": "f054e6818584024d2f3acb36f2ebfa044546d72d203ecf06b758bfb4c2a8dcfc"
|
|
24
24
|
},
|
|
25
25
|
"provenance": {
|
|
26
26
|
"builder": "scripts/build-rust-local.sh",
|
|
27
|
-
"sourceCommit": "
|
|
27
|
+
"sourceCommit": "30eda7a2ba11647e835b0079c526bed77f189c8e",
|
|
28
28
|
"sourceTreeDirty": false,
|
|
29
29
|
"sourceDateEpoch": 1,
|
|
30
30
|
"rustToolchain": "rustc 1.95.0 (59807616e 2026-04-14)",
|
|
@@ -33,6 +33,6 @@
|
|
|
33
33
|
"signature": {
|
|
34
34
|
"algorithm": "Ed25519",
|
|
35
35
|
"keyId": "e2ee765cb5ad9fbdc433ece332bce26d746d5e350b3acde0721a4c8c6337ff7b",
|
|
36
|
-
"value": "
|
|
36
|
+
"value": "i9Ts/DMWmNUhyq+YNId6aNr4Y+5GGr8CPIe0fXqtBLcXoqGlxgxq83PMgvnFX0fG0pa8MI+Fv7fbamFQ4/SWBg=="
|
|
37
37
|
}
|
|
38
38
|
}
|
|
@@ -4,14 +4,14 @@
|
|
|
4
4
|
"version": 1,
|
|
5
5
|
"metadata": {
|
|
6
6
|
"component": {
|
|
7
|
-
"bom-ref": "pkg:cargo/planu-core@5.3.
|
|
7
|
+
"bom-ref": "pkg:cargo/planu-core@5.3.3",
|
|
8
8
|
"type": "library",
|
|
9
9
|
"name": "planu-core",
|
|
10
|
-
"version": "5.3.
|
|
10
|
+
"version": "5.3.3",
|
|
11
11
|
"hashes": [
|
|
12
12
|
{
|
|
13
13
|
"alg": "SHA-256",
|
|
14
|
-
"content": "
|
|
14
|
+
"content": "5f4ce40b9f76cfaa1fdc25ba8ce765cc3fd7fc0b38bbe10ce8a5b3ba5b0dcc1a"
|
|
15
15
|
}
|
|
16
16
|
]
|
|
17
17
|
}
|
|
@@ -1106,7 +1106,7 @@
|
|
|
1106
1106
|
"dependsOn": []
|
|
1107
1107
|
},
|
|
1108
1108
|
{
|
|
1109
|
-
"ref": "pkg:cargo/planu-core@5.3.
|
|
1109
|
+
"ref": "pkg:cargo/planu-core@5.3.3",
|
|
1110
1110
|
"dependsOn": [
|
|
1111
1111
|
"pkg:cargo/core-foundation@0.10.1",
|
|
1112
1112
|
"pkg:cargo/hmac@0.12.1",
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schemaVersion": 2,
|
|
3
|
-
"cliVersion": "5.3.
|
|
4
|
-
"engineVersion": "5.3.
|
|
5
|
-
"buildId": "1-5.3.
|
|
3
|
+
"cliVersion": "5.3.3",
|
|
4
|
+
"engineVersion": "5.3.3",
|
|
5
|
+
"buildId": "1-5.3.3-x86_64-pc-windows-msvc",
|
|
6
6
|
"protocolAbi": 1,
|
|
7
7
|
"capabilityAbi": 2,
|
|
8
8
|
"nodeApiAbi": 4,
|
|
@@ -16,15 +16,15 @@
|
|
|
16
16
|
"platform": "win32",
|
|
17
17
|
"arch": "x64",
|
|
18
18
|
"libc": null,
|
|
19
|
-
"artifactSha256": "
|
|
19
|
+
"artifactSha256": "5da0cd500b980f5a7295886298ea9176905bc38190fee2a7eae3359ec59513e9",
|
|
20
20
|
"sbom": {
|
|
21
21
|
"format": "CycloneDX-1.5",
|
|
22
22
|
"path": "planu-core.win32-x64-msvc.node.sbom.json",
|
|
23
|
-
"sha256": "
|
|
23
|
+
"sha256": "a8f35671f2c924ba92ed04e0a9513262c9005451c04423c11e13a9d8371164bc"
|
|
24
24
|
},
|
|
25
25
|
"provenance": {
|
|
26
26
|
"builder": "scripts/build-rust-local.sh",
|
|
27
|
-
"sourceCommit": "
|
|
27
|
+
"sourceCommit": "30eda7a2ba11647e835b0079c526bed77f189c8e",
|
|
28
28
|
"sourceTreeDirty": false,
|
|
29
29
|
"sourceDateEpoch": 1,
|
|
30
30
|
"rustToolchain": "rustc 1.95.0 (59807616e 2026-04-14)",
|
|
@@ -33,6 +33,6 @@
|
|
|
33
33
|
"signature": {
|
|
34
34
|
"algorithm": "Ed25519",
|
|
35
35
|
"keyId": "e2ee765cb5ad9fbdc433ece332bce26d746d5e350b3acde0721a4c8c6337ff7b",
|
|
36
|
-
"value": "
|
|
36
|
+
"value": "yN+zsORULiFYmazE/LZm1vp2+Nd3NpEqE/1IKcjW3OHz/Nqk9jN1Hj1CR78aDGcJHiMSMobSiBdCviN9dvM4CA=="
|
|
37
37
|
}
|
|
38
38
|
}
|
|
@@ -4,14 +4,14 @@
|
|
|
4
4
|
"version": 1,
|
|
5
5
|
"metadata": {
|
|
6
6
|
"component": {
|
|
7
|
-
"bom-ref": "pkg:cargo/planu-core@5.3.
|
|
7
|
+
"bom-ref": "pkg:cargo/planu-core@5.3.3",
|
|
8
8
|
"type": "library",
|
|
9
9
|
"name": "planu-core",
|
|
10
|
-
"version": "5.3.
|
|
10
|
+
"version": "5.3.3",
|
|
11
11
|
"hashes": [
|
|
12
12
|
{
|
|
13
13
|
"alg": "SHA-256",
|
|
14
|
-
"content": "
|
|
14
|
+
"content": "5da0cd500b980f5a7295886298ea9176905bc38190fee2a7eae3359ec59513e9"
|
|
15
15
|
}
|
|
16
16
|
]
|
|
17
17
|
}
|
|
@@ -1106,7 +1106,7 @@
|
|
|
1106
1106
|
"dependsOn": []
|
|
1107
1107
|
},
|
|
1108
1108
|
{
|
|
1109
|
-
"ref": "pkg:cargo/planu-core@5.3.
|
|
1109
|
+
"ref": "pkg:cargo/planu-core@5.3.3",
|
|
1110
1110
|
"dependsOn": [
|
|
1111
1111
|
"pkg:cargo/core-foundation@0.10.1",
|
|
1112
1112
|
"pkg:cargo/hmac@0.12.1",
|
|
@@ -100,6 +100,20 @@ const runtimePolicySchema = z
|
|
|
100
100
|
minimumHeartbeatMs: z.number().int().positive(),
|
|
101
101
|
})
|
|
102
102
|
.strict(),
|
|
103
|
+
outbox: z
|
|
104
|
+
.object({
|
|
105
|
+
sliceRecordBudget: z.number().int().positive(),
|
|
106
|
+
sliceTimeBudgetMs: z.number().int().positive(),
|
|
107
|
+
backoffBaseMs: z.number().int().positive(),
|
|
108
|
+
backoffCapMs: z.number().int().positive(),
|
|
109
|
+
maxAttempts: z.number().int().positive(),
|
|
110
|
+
diagnosticMaxLength: z.number().int().positive(),
|
|
111
|
+
continuationDelayMs: z.number().int().positive(),
|
|
112
|
+
})
|
|
113
|
+
.strict()
|
|
114
|
+
.refine((value) => value.backoffBaseMs <= value.backoffCapMs, {
|
|
115
|
+
message: 'backoffBaseMs must not exceed backoffCapMs',
|
|
116
|
+
}),
|
|
103
117
|
})
|
|
104
118
|
.strict();
|
|
105
119
|
const require = createRequire(import.meta.url);
|
package/dist/index.js
CHANGED
|
@@ -126,7 +126,9 @@ async function setupPostHandshake(handshakeGate, officialToolNames) {
|
|
|
126
126
|
})();
|
|
127
127
|
// SPEC-334: Sync planu/ YAML frontmatter status → data/ store (non-blocking)
|
|
128
128
|
scheduleStartupStateRecovery(startupSync, recoverPendingPostCommitTasksAtStartup);
|
|
129
|
-
|
|
129
|
+
// SPEC-1271: bounded, fair, durable spec.created outbox recovery — fire-and-forget so a
|
|
130
|
+
// large or poisoned backlog never delays post-handshake completion (mirrors serve.ts).
|
|
131
|
+
void startSpecCreatedOutboxConsumer().catch((err) => {
|
|
130
132
|
console.error('[Planu] Startup spec.created outbox recovery failed:', err);
|
|
131
133
|
});
|
|
132
134
|
try {
|
|
@@ -167,8 +167,30 @@ export declare class RuntimeDatabase implements Disposable {
|
|
|
167
167
|
readonly pendingOnly?: boolean;
|
|
168
168
|
readonly limit?: number;
|
|
169
169
|
readonly topics?: readonly string[];
|
|
170
|
+
/**
|
|
171
|
+
* When set, applies fence-based eligibility filtering (excludes dead-lettered
|
|
172
|
+
* records and records whose durable `nextAttemptAt` is still in the future)
|
|
173
|
+
* BEFORE the LIMIT clause, so ineligible records cannot head-of-line block
|
|
174
|
+
* an eligible record behind them in the same scope.
|
|
175
|
+
*/
|
|
176
|
+
readonly eligibleAt?: string;
|
|
177
|
+
/** Only rows with `id > afterId`, for paginating past ineligible head records. */
|
|
178
|
+
readonly afterId?: number;
|
|
170
179
|
}): readonly RuntimeOutboxRecord[];
|
|
171
|
-
|
|
180
|
+
/**
|
|
181
|
+
* Counts pending records that would be eligible for delivery right now, without
|
|
182
|
+
* materializing them. Used to report an exact `skipped` count when a bounded
|
|
183
|
+
* slice stops early (budget or time), rather than inferring it from the LIMIT.
|
|
184
|
+
*/
|
|
185
|
+
countEligibleOutbox(projectId: string, workspaceId: string, options: {
|
|
186
|
+
readonly topics?: readonly string[];
|
|
187
|
+
readonly eligibleAt: string;
|
|
188
|
+
readonly afterId?: number;
|
|
189
|
+
}): number;
|
|
190
|
+
listPendingOutboxScopes(options?: {
|
|
191
|
+
readonly topics?: readonly string[];
|
|
192
|
+
readonly eligibleAt?: string;
|
|
193
|
+
}): readonly RuntimeOutboxScope[];
|
|
172
194
|
markOutboxDelivered(projectId: string, workspaceId: string, id: number, deliveredAt?: string): boolean;
|
|
173
195
|
appendAudit(input: {
|
|
174
196
|
readonly projectId: string;
|
|
@@ -6,6 +6,36 @@ import { parseValidationReceiptPendingCheckpoint, } from '../types/durable-valid
|
|
|
6
6
|
const CURRENT_SCHEMA_VERSION = 4;
|
|
7
7
|
const DEFAULT_BUSY_TIMEOUT_MS = 5_000;
|
|
8
8
|
const DEFAULT_MAX_CONNECTIONS = 4;
|
|
9
|
+
/**
|
|
10
|
+
* Joins each outbox row to its fence record (stored as a `mutation` runtime record
|
|
11
|
+
* keyed `outbox-fence:<id>`) so eligibility can be evaluated in SQL before LIMIT.
|
|
12
|
+
*/
|
|
13
|
+
const OUTBOX_FENCE_JOIN = `
|
|
14
|
+
LEFT JOIN runtime_records fence
|
|
15
|
+
ON fence.project_id = o.project_id AND fence.workspace_id = o.workspace_id
|
|
16
|
+
AND fence.kind = 'mutation' AND fence.record_key = 'outbox-fence:' || o.id`;
|
|
17
|
+
/**
|
|
18
|
+
* Excludes dead-lettered records and records whose durable `nextAttemptAt` retry
|
|
19
|
+
* deadline is still in the future. Records with no fence, or a fence without
|
|
20
|
+
* `nextAttemptAt` (schemaVersion 1 legacy), remain eligible.
|
|
21
|
+
*
|
|
22
|
+
* A fence with invalid JSON, or a `nextAttemptAt` that is not ISO-8601-shaped,
|
|
23
|
+
* is deliberately left ELIGIBLE rather than filtered: a non-ISO string compares
|
|
24
|
+
* lexicographically against the `?` bound and can hide the record from every
|
|
25
|
+
* future slice, so malformed fences must surface to claim()/parseFence as a
|
|
26
|
+
* `Corrupt` failure instead of vanishing silently.
|
|
27
|
+
*/
|
|
28
|
+
const OUTBOX_ELIGIBILITY_CLAUSE = `
|
|
29
|
+
AND (fence.value_json IS NULL
|
|
30
|
+
OR NOT json_valid(fence.value_json)
|
|
31
|
+
OR json_extract(fence.value_json, '$.state') IS NULL
|
|
32
|
+
OR json_extract(fence.value_json, '$.state') != 'dead-letter')
|
|
33
|
+
AND (fence.value_json IS NULL
|
|
34
|
+
OR NOT json_valid(fence.value_json)
|
|
35
|
+
OR json_extract(fence.value_json, '$.nextAttemptAt') IS NULL
|
|
36
|
+
OR json_extract(fence.value_json, '$.nextAttemptAt')
|
|
37
|
+
NOT GLOB '[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]*'
|
|
38
|
+
OR json_extract(fence.value_json, '$.nextAttemptAt') <= ?)`;
|
|
9
39
|
const openConnections = new Map();
|
|
10
40
|
let integrityCheckedOpens = 0;
|
|
11
41
|
let integrityBypassedOpens = 0;
|
|
@@ -746,20 +776,60 @@ export class RuntimeDatabase {
|
|
|
746
776
|
requireIdentifier(topic, 'outbox topic');
|
|
747
777
|
}
|
|
748
778
|
const topicsClause = topics.length > 0 ? ` AND topic IN (${topics.map(() => '?').join(', ')})` : '';
|
|
779
|
+
const afterClause = options.afterId !== undefined ? ' AND o.id > ?' : '';
|
|
780
|
+
const afterParams = options.afterId !== undefined ? [options.afterId] : [];
|
|
781
|
+
const eligibilityJoin = options.eligibleAt !== undefined ? OUTBOX_FENCE_JOIN : '';
|
|
782
|
+
const eligibilityClause = options.eligibleAt !== undefined ? OUTBOX_ELIGIBILITY_CLAUSE : '';
|
|
783
|
+
const eligibilityParams = options.eligibleAt !== undefined ? [options.eligibleAt] : [];
|
|
749
784
|
const rows = this.database
|
|
750
|
-
.prepare(`SELECT id, project_id, workspace_id
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
785
|
+
.prepare(`SELECT o.id AS id, o.project_id AS project_id, o.workspace_id AS workspace_id,
|
|
786
|
+
o.topic AS topic, o.payload_json AS payload_json,
|
|
787
|
+
o.created_at AS created_at, o.delivered_at AS delivered_at
|
|
788
|
+
FROM outbox o${eligibilityJoin}
|
|
789
|
+
WHERE o.project_id = ? AND o.workspace_id = ?${pendingClause}${topicsClause}${afterClause}${eligibilityClause}
|
|
790
|
+
ORDER BY o.id LIMIT ?`)
|
|
791
|
+
.all(projectId, workspaceId, ...topics, ...afterParams, ...eligibilityParams, limit);
|
|
755
792
|
return rows.map(mapOutboxRow);
|
|
756
793
|
}
|
|
757
|
-
|
|
794
|
+
/**
|
|
795
|
+
* Counts pending records that would be eligible for delivery right now, without
|
|
796
|
+
* materializing them. Used to report an exact `skipped` count when a bounded
|
|
797
|
+
* slice stops early (budget or time), rather than inferring it from the LIMIT.
|
|
798
|
+
*/
|
|
799
|
+
countEligibleOutbox(projectId, workspaceId, options) {
|
|
800
|
+
this.assertOpen();
|
|
801
|
+
requireIdentifier(projectId, 'projectId');
|
|
802
|
+
requireIdentifier(workspaceId, 'workspaceId');
|
|
803
|
+
const topics = options.topics ?? [];
|
|
804
|
+
for (const topic of topics) {
|
|
805
|
+
requireIdentifier(topic, 'outbox topic');
|
|
806
|
+
}
|
|
807
|
+
const topicsClause = topics.length > 0 ? ` AND o.topic IN (${topics.map(() => '?').join(', ')})` : '';
|
|
808
|
+
const afterClause = options.afterId !== undefined ? ' AND o.id > ?' : '';
|
|
809
|
+
const afterParams = options.afterId !== undefined ? [options.afterId] : [];
|
|
810
|
+
const row = this.database
|
|
811
|
+
.prepare(`SELECT COUNT(*) AS count
|
|
812
|
+
FROM outbox o${OUTBOX_FENCE_JOIN}
|
|
813
|
+
WHERE o.project_id = ? AND o.workspace_id = ? AND o.delivered_at IS NULL${topicsClause}${afterClause}${OUTBOX_ELIGIBILITY_CLAUSE}`)
|
|
814
|
+
.get(projectId, workspaceId, ...topics, ...afterParams, options.eligibleAt);
|
|
815
|
+
return row.count;
|
|
816
|
+
}
|
|
817
|
+
listPendingOutboxScopes(options = {}) {
|
|
758
818
|
this.assertOpen();
|
|
819
|
+
const topics = options.topics ?? [];
|
|
820
|
+
for (const topic of topics) {
|
|
821
|
+
requireIdentifier(topic, 'outbox topic');
|
|
822
|
+
}
|
|
823
|
+
const topicsClause = topics.length > 0 ? ` AND o.topic IN (${topics.map(() => '?').join(', ')})` : '';
|
|
824
|
+
const eligibilityJoin = options.eligibleAt !== undefined ? OUTBOX_FENCE_JOIN : '';
|
|
825
|
+
const eligibilityClause = options.eligibleAt !== undefined ? OUTBOX_ELIGIBILITY_CLAUSE : '';
|
|
826
|
+
const eligibilityParams = options.eligibleAt !== undefined ? [options.eligibleAt] : [];
|
|
759
827
|
return this.database
|
|
760
|
-
.prepare(`SELECT DISTINCT project_id AS projectId, workspace_id AS workspaceId
|
|
761
|
-
FROM outbox
|
|
762
|
-
|
|
828
|
+
.prepare(`SELECT DISTINCT o.project_id AS projectId, o.workspace_id AS workspaceId
|
|
829
|
+
FROM outbox o${eligibilityJoin}
|
|
830
|
+
WHERE o.delivered_at IS NULL${topicsClause}${eligibilityClause}
|
|
831
|
+
ORDER BY o.project_id, o.workspace_id`)
|
|
832
|
+
.all(...topics, ...eligibilityParams);
|
|
763
833
|
}
|
|
764
834
|
markOutboxDelivered(projectId, workspaceId, id, deliveredAt = new Date().toISOString()) {
|
|
765
835
|
this.assertOpen();
|
|
@@ -8,6 +8,8 @@ import { incrementSpecCount } from '../../engine/autopilot/state-updater.js';
|
|
|
8
8
|
import { join } from 'node:path';
|
|
9
9
|
import { hashProjectPath, projectDataDir } from '../../storage/base-store.js';
|
|
10
10
|
import { appendAutopilotLogEntry } from '../../storage/autopilot-log-store.js';
|
|
11
|
+
import { getRuntimePolicy } from '../../engine/runtime-policy.js';
|
|
12
|
+
import { redactText } from '../../security/redactor.js';
|
|
11
13
|
import { analyzeContextPreflight, buildTokenWasteReport, loadTokenWastePolicy, recommendRelevantTools, toolsFromPolicyGroups, } from '../../engine/token-optimizer/index.js';
|
|
12
14
|
import { atomicWriteFile } from '../../engine/safety/atomic-write-file.js';
|
|
13
15
|
const ASYNC_ANALYSIS_HOOK = 'create-spec-async-analysis';
|
|
@@ -52,7 +54,9 @@ export async function buildInitialTokenWasteMetadata(specId, projectPath, descri
|
|
|
52
54
|
}
|
|
53
55
|
/** Auto-setup git branch (non-blocking). Returns branch info or undefined. */
|
|
54
56
|
export async function setupGitBranch(projectId, specId) {
|
|
55
|
-
|
|
57
|
+
// SPEC-1396: runs from durable outbox delivery (including startup recovery) — never
|
|
58
|
+
// move the shared checkout, or replaying a backlog walks HEAD through every spec branch.
|
|
59
|
+
const result = await tryAutoSetupGit(projectId, specId, { mutateCheckout: false });
|
|
56
60
|
/* v8 ignore start -- requires real git repo */
|
|
57
61
|
if (result) {
|
|
58
62
|
return { branch: result.branch, data: result.data };
|
|
@@ -254,7 +258,8 @@ export async function runAutopilotAnalysis(specId, projectPath, description) {
|
|
|
254
258
|
});
|
|
255
259
|
}
|
|
256
260
|
catch (err) {
|
|
257
|
-
const
|
|
261
|
+
const raw = err instanceof Error ? err.message : String(err);
|
|
262
|
+
const error = redactText(raw).slice(0, getRuntimePolicy().outbox.diagnosticMaxLength);
|
|
258
263
|
await appendAutopilotLogEntry(projectId, {
|
|
259
264
|
specId,
|
|
260
265
|
hookName: ASYNC_ANALYSIS_HOOK,
|
|
@@ -11,4 +11,6 @@ export declare class SpecCreatedOutboxConsumer {
|
|
|
11
11
|
}
|
|
12
12
|
export declare function drainSpecCreatedOutbox(databasePath?: string): Promise<void>;
|
|
13
13
|
export declare function startSpecCreatedOutboxConsumer(): Promise<void>;
|
|
14
|
+
/** Test-only: clear scheduler state so fixtures do not leak timers across tests. */
|
|
15
|
+
export declare function resetSpecCreatedOutboxSchedulerForTests(): void;
|
|
14
16
|
//# sourceMappingURL=spec-created-outbox-consumer.d.ts.map
|
|
@@ -6,6 +6,13 @@ import { specStore } from '../../storage/index.js';
|
|
|
6
6
|
import { runAutopilotAnalysis, setupGitBranch } from './post-creation.js';
|
|
7
7
|
import { SPEC_CREATED_POST_COMMIT_TASKS, } from '../../types/outbox-worker.js';
|
|
8
8
|
import { TransactionalOutboxWorker } from '../../engine/execution/outbox-worker.js';
|
|
9
|
+
import { getRuntimePolicy } from '../../engine/runtime-policy.js';
|
|
10
|
+
import { redactText } from '../../security/redactor.js';
|
|
11
|
+
const SPEC_CREATED_TOPICS = ['spec.created'];
|
|
12
|
+
/** Redact and bound a raw thrown diagnostic before it reaches durable storage. */
|
|
13
|
+
function sanitizeDiagnostic(message) {
|
|
14
|
+
return redactText(message).slice(0, getRuntimePolicy().outbox.diagnosticMaxLength);
|
|
15
|
+
}
|
|
9
16
|
export { SPEC_CREATED_POST_COMMIT_TASKS } from '../../types/outbox-worker.js';
|
|
10
17
|
const RESPONSE_ONLY_REASON = 'Advisory task is intentionally terminal because it has no durable post-commit side effect.';
|
|
11
18
|
function isObject(value) {
|
|
@@ -123,6 +130,7 @@ export class SpecCreatedOutboxConsumer {
|
|
|
123
130
|
});
|
|
124
131
|
}
|
|
125
132
|
catch (error) {
|
|
133
|
+
const raw = error instanceof Error ? error.message : String(error);
|
|
126
134
|
this.database.appendAudit({
|
|
127
135
|
projectId: payload.projectId,
|
|
128
136
|
workspaceId: 'default',
|
|
@@ -130,7 +138,7 @@ export class SpecCreatedOutboxConsumer {
|
|
|
130
138
|
payload: {
|
|
131
139
|
deliveryKey: context.deliveryKey,
|
|
132
140
|
task,
|
|
133
|
-
error:
|
|
141
|
+
error: sanitizeDiagnostic(raw),
|
|
134
142
|
},
|
|
135
143
|
});
|
|
136
144
|
throw error;
|
|
@@ -140,6 +148,8 @@ export class SpecCreatedOutboxConsumer {
|
|
|
140
148
|
}
|
|
141
149
|
let interval;
|
|
142
150
|
let activeDrain;
|
|
151
|
+
let continuationTimer;
|
|
152
|
+
let rotationCursor = 0;
|
|
143
153
|
export function drainSpecCreatedOutbox(databasePath = resolveStorageLayout().runtimeDatabase) {
|
|
144
154
|
if (activeDrain) {
|
|
145
155
|
return activeDrain;
|
|
@@ -147,11 +157,7 @@ export function drainSpecCreatedOutbox(databasePath = resolveStorageLayout().run
|
|
|
147
157
|
activeDrain = (async () => {
|
|
148
158
|
const database = new RuntimeDatabase({ path: databasePath });
|
|
149
159
|
try {
|
|
150
|
-
|
|
151
|
-
for (const scope of database.listPendingOutboxScopes()) {
|
|
152
|
-
const worker = new TransactionalOutboxWorker(database, scope.projectId, scope.workspaceId, `spec-created-${process.pid}-${randomUUID()}`);
|
|
153
|
-
await worker.drain((context) => consumer.deliver(context), { topics: ['spec.created'] });
|
|
154
|
-
}
|
|
160
|
+
await drainBoundedSlice(database, databasePath);
|
|
155
161
|
}
|
|
156
162
|
finally {
|
|
157
163
|
database.close();
|
|
@@ -161,6 +167,75 @@ export function drainSpecCreatedOutbox(databasePath = resolveStorageLayout().run
|
|
|
161
167
|
});
|
|
162
168
|
return activeDrain;
|
|
163
169
|
}
|
|
170
|
+
/**
|
|
171
|
+
* Processes one bounded, fair slice of the spec.created backlog: round-robins
|
|
172
|
+
* pending scopes one eligible record at a time (SQL already excludes dead-lettered
|
|
173
|
+
* and not-yet-due records, so a poison record cannot head-of-line block a healthy
|
|
174
|
+
* scope) until the record or elapsed-time budget is exhausted. If work remains,
|
|
175
|
+
* schedules a single unref()'d continuation instead of blocking the caller.
|
|
176
|
+
*/
|
|
177
|
+
async function drainBoundedSlice(database, databasePath) {
|
|
178
|
+
const policy = getRuntimePolicy().outbox;
|
|
179
|
+
const consumer = new SpecCreatedOutboxConsumer(database);
|
|
180
|
+
const discovered = database.listPendingOutboxScopes({
|
|
181
|
+
topics: [...SPEC_CREATED_TOPICS],
|
|
182
|
+
eligibleAt: new Date().toISOString(),
|
|
183
|
+
});
|
|
184
|
+
if (discovered.length === 0) {
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
const offset = rotationCursor % discovered.length;
|
|
188
|
+
const activeScopes = [...discovered.slice(offset), ...discovered.slice(0, offset)];
|
|
189
|
+
const startedAtMs = Date.now();
|
|
190
|
+
let recordBudget = policy.sliceRecordBudget;
|
|
191
|
+
let turnsTaken = 0;
|
|
192
|
+
let index = 0;
|
|
193
|
+
while (activeScopes.length > 0 && recordBudget > 0) {
|
|
194
|
+
if (Date.now() - startedAtMs > policy.sliceTimeBudgetMs) {
|
|
195
|
+
break;
|
|
196
|
+
}
|
|
197
|
+
const position = index % activeScopes.length;
|
|
198
|
+
const scope = activeScopes[position];
|
|
199
|
+
if (!scope) {
|
|
200
|
+
break;
|
|
201
|
+
}
|
|
202
|
+
const worker = new TransactionalOutboxWorker(database, scope.projectId, scope.workspaceId, `spec-created-${process.pid}-${randomUUID()}`);
|
|
203
|
+
const result = await worker.drain((context) => consumer.deliver(context), {
|
|
204
|
+
topics: [...SPEC_CREATED_TOPICS],
|
|
205
|
+
limit: 1,
|
|
206
|
+
});
|
|
207
|
+
turnsTaken += 1;
|
|
208
|
+
// Only actually-processed records consume the pass budget; ineligible-only
|
|
209
|
+
// turns (foreign lease, self-healed delivered fence) are free.
|
|
210
|
+
recordBudget -= result.delivered + result.failed;
|
|
211
|
+
if (result.exhausted) {
|
|
212
|
+
// No eligible record remains in this scope right now — drop it from this
|
|
213
|
+
// pass's rotation instead of spinning on it. A failed-but-not-exhausted
|
|
214
|
+
// scope stays in rotation so a poison record cannot block healthy work
|
|
215
|
+
// behind it.
|
|
216
|
+
activeScopes.splice(position, 1);
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
219
|
+
index += 1;
|
|
220
|
+
}
|
|
221
|
+
rotationCursor += turnsTaken;
|
|
222
|
+
if (activeScopes.length > 0) {
|
|
223
|
+
scheduleContinuation(databasePath);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
function scheduleContinuation(databasePath) {
|
|
227
|
+
if (continuationTimer) {
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
const policy = getRuntimePolicy().outbox;
|
|
231
|
+
continuationTimer = setTimeout(() => {
|
|
232
|
+
continuationTimer = undefined;
|
|
233
|
+
void drainSpecCreatedOutbox(databasePath).catch((error) => {
|
|
234
|
+
console.error('[Planu] spec.created outbox continuation failed:', error);
|
|
235
|
+
});
|
|
236
|
+
}, policy.continuationDelayMs);
|
|
237
|
+
continuationTimer.unref();
|
|
238
|
+
}
|
|
164
239
|
export async function startSpecCreatedOutboxConsumer() {
|
|
165
240
|
await drainSpecCreatedOutbox();
|
|
166
241
|
if (interval) {
|
|
@@ -173,4 +248,17 @@ export async function startSpecCreatedOutboxConsumer() {
|
|
|
173
248
|
}, 30_000);
|
|
174
249
|
interval.unref();
|
|
175
250
|
}
|
|
251
|
+
/** Test-only: clear scheduler state so fixtures do not leak timers across tests. */
|
|
252
|
+
export function resetSpecCreatedOutboxSchedulerForTests() {
|
|
253
|
+
if (interval) {
|
|
254
|
+
clearInterval(interval);
|
|
255
|
+
interval = undefined;
|
|
256
|
+
}
|
|
257
|
+
if (continuationTimer) {
|
|
258
|
+
clearTimeout(continuationTimer);
|
|
259
|
+
continuationTimer = undefined;
|
|
260
|
+
}
|
|
261
|
+
activeDrain = undefined;
|
|
262
|
+
rotationCursor = 0;
|
|
263
|
+
}
|
|
176
264
|
//# sourceMappingURL=spec-created-outbox-consumer.js.map
|
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import type { GitSetupResult } from '../types/index.js';
|
|
1
|
+
import type { GitSetupResult, GitAutoSetupOptions } from '../types/index.js';
|
|
2
2
|
export declare function slugify(text: string): string;
|
|
3
3
|
export declare function generateSpecId(existingSpecs: {
|
|
4
4
|
id: string;
|
|
5
5
|
}[]): string;
|
|
6
6
|
export declare function generateBranchName(specId: string, slug: string, type: string): string;
|
|
7
7
|
/** Try auto-setup git branch. Returns result or undefined on failure. */
|
|
8
|
-
export declare function tryAutoSetupGit(projectId: string, specId: string): Promise<GitSetupResult | undefined>;
|
|
8
|
+
export declare function tryAutoSetupGit(projectId: string, specId: string, options?: GitAutoSetupOptions): Promise<GitSetupResult | undefined>;
|
|
9
9
|
//# sourceMappingURL=create-spec-helpers.d.ts.map
|
|
@@ -29,10 +29,10 @@ export function generateBranchName(specId, slug, type) {
|
|
|
29
29
|
return `${BRANCH_PREFIXES[type] ?? 'feat'}/${specId.toLowerCase()}-${slug}`;
|
|
30
30
|
}
|
|
31
31
|
/** Try auto-setup git branch. Returns result or undefined on failure. */
|
|
32
|
-
export async function tryAutoSetupGit(projectId, specId) {
|
|
32
|
+
export async function tryAutoSetupGit(projectId, specId, options) {
|
|
33
33
|
/* v8 ignore start -- requires real git repo with branches */
|
|
34
34
|
try {
|
|
35
|
-
const r = await handleAutoSetup(projectId, specId);
|
|
35
|
+
const r = await handleAutoSetup(projectId, specId, undefined, options);
|
|
36
36
|
if (!r.isError && r.content[0]?.type === 'text') {
|
|
37
37
|
const data = JSON.parse(r.content[0].text);
|
|
38
38
|
if (typeof data.newBranch !== 'string') {
|
|
@@ -1,12 +1,16 @@
|
|
|
1
|
-
import type { ToolResult, GitConfig } from '../../types/index.js';
|
|
2
|
-
export declare function handleCreateBranch(projectId: string, specId: string | undefined, config?: GitConfig, resolvedProjectPath?: string): Promise<ToolResult>;
|
|
1
|
+
import type { ToolResult, GitConfig, GitAutoSetupOptions } from '../../types/index.js';
|
|
2
|
+
export declare function handleCreateBranch(projectId: string, specId: string | undefined, config?: GitConfig, resolvedProjectPath?: string, options?: GitAutoSetupOptions): Promise<ToolResult>;
|
|
3
3
|
export declare function handleCheckBranch(projectId: string, _specId: string | undefined, config?: GitConfig): Promise<ToolResult>;
|
|
4
4
|
/**
|
|
5
5
|
* Auto-setup: detect git workflow, sync base branch, create feature branch.
|
|
6
6
|
* Combines detectGitFlowType + resolveBaseBranch + fetch/pull + create-branch
|
|
7
7
|
* into a single atomic operation for spec creation.
|
|
8
|
+
*
|
|
9
|
+
* SPEC-1396: `mutateCheckout: false` creates the branch ref without touching HEAD
|
|
10
|
+
* or the working tree. Automatic callers (outbox recovery, cascades) MUST use it —
|
|
11
|
+
* a background checkout silently moves the shared checkout onto a foreign branch.
|
|
8
12
|
*/
|
|
9
|
-
export declare function handleAutoSetup(projectId: string, specId: string | undefined, config?: GitConfig): Promise<ToolResult>;
|
|
13
|
+
export declare function handleAutoSetup(projectId: string, specId: string | undefined, config?: GitConfig, options?: GitAutoSetupOptions): Promise<ToolResult>;
|
|
10
14
|
/**
|
|
11
15
|
* Returns a structured worktree-start payload with commands/paths so the caller
|
|
12
16
|
* knows exactly how to spin up an isolated Claude Code session.
|