mandrel 1.76.0 → 1.78.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/.agents/docs/configuration.md +2 -2
- package/.agents/docs/workflows.md +19 -0
- package/.agents/schemas/agentrc.schema.json +1 -1
- package/.agents/schemas/dispatch-manifest.json +1 -1
- package/.agents/schemas/lifecycle/loop.tick.schema.json +20 -0
- package/.agents/schemas/loop-unit.schema.json +70 -0
- package/.agents/schemas/validation-evidence.schema.json +2 -1
- package/.agents/scripts/audit-to-stories.js +43 -1
- package/.agents/scripts/check-doc-links.js +24 -1
- package/.agents/scripts/check-loop-units.js +204 -0
- package/.agents/scripts/epic-deliver-prepare.js +31 -0
- package/.agents/scripts/evidence-gate.js +48 -12
- package/.agents/scripts/generate-workflows-doc.js +37 -4
- package/.agents/scripts/lib/audit-to-stories/build-story-body.js +141 -34
- package/.agents/scripts/lib/cli-args.js +6 -0
- package/.agents/scripts/lib/close-validation/process.js +61 -5
- package/.agents/scripts/lib/close-validation/runner.js +42 -9
- package/.agents/scripts/lib/config/temp-paths.js +1 -1
- package/.agents/scripts/lib/config/worktree-isolation.js +18 -3
- package/.agents/scripts/lib/config-resolver.js +4 -1
- package/.agents/scripts/lib/config-settings-schema-delivery.js +1 -1
- package/.agents/scripts/lib/git-branch-lifecycle.js +90 -0
- package/.agents/scripts/lib/loop-units/validate-loop-unit.js +197 -0
- package/.agents/scripts/lib/mandrel-catalog.js +36 -0
- package/.agents/scripts/lib/orchestration/auto-merge-cwd.js +128 -0
- package/.agents/scripts/lib/orchestration/column-sync.js +88 -9
- package/.agents/scripts/lib/orchestration/lifecycle/emit-loop-tick.js +183 -0
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/automerge-armer.js +20 -2
- package/.agents/scripts/lib/orchestration/project-meta-cache.js +238 -0
- package/.agents/scripts/lib/orchestration/reassert-status-column.js +3 -1
- package/.agents/scripts/lib/orchestration/single-story-close/phases/auto-merge.js +25 -2
- package/.agents/scripts/lib/orchestration/single-story-close/phases/close-validation.js +80 -14
- package/.agents/scripts/lib/orchestration/single-story-close/runner.js +74 -25
- package/.agents/scripts/lib/orchestration/story-close/phases/locked-pipeline.js +10 -1
- package/.agents/scripts/lib/orchestration/ticket-validator-conflicts.js +48 -1
- package/.agents/scripts/lib/orchestration/ticket-validator-sizing.js +148 -4
- package/.agents/scripts/lib/orchestration/ticketing/transition.js +8 -1
- package/.agents/scripts/lib/story-body/story-body.js +76 -7
- package/.agents/scripts/lib/story-init/branch-initializer.js +29 -43
- package/.agents/scripts/lib/story-init/hierarchy-tracer.js +25 -4
- package/.agents/scripts/lib/story-init/task-graph-builder.js +22 -12
- package/.agents/scripts/lib/templates/decomposer-prompts.js +23 -0
- package/.agents/scripts/lib/validation-evidence.js +63 -25
- package/.agents/scripts/lib/worktree/node-modules-strategy.js +239 -31
- package/.agents/scripts/providers/github/branch-protection.js +1 -1
- package/.agents/scripts/providers/github/errors.js +53 -2
- package/.agents/scripts/providers/github/labels.js +1 -1
- package/.agents/scripts/providers/github/projects-v2-graphql.js +1 -1
- package/.agents/scripts/resync-status-column.js +5 -0
- package/.agents/scripts/run-coverage.js +85 -45
- package/.agents/scripts/run-lint.js +11 -0
- package/.agents/scripts/single-story-init.js +22 -29
- package/.agents/scripts/story-init.js +38 -63
- package/.agents/scripts/story-phase.js +46 -4
- package/.agents/scripts/sync-claude-commands.js +112 -29
- package/.agents/scripts/update-maintainability-baseline.js +19 -76
- package/.agents/skills/core/epic-plan-decompose-author/SKILL.md +5 -3
- package/.agents/workflows/helpers/acceptance-self-eval.md +27 -0
- package/.agents/workflows/helpers/deliver-epic.md +19 -2
- package/.agents/workflows/helpers/epic-deliver-story.md +50 -14
- package/.agents/workflows/helpers/single-story-deliver.md +12 -0
- package/.agents/workflows/loops/README.md +65 -0
- package/.agents/workflows/loops/fix-failing-tests.md +74 -0
- package/.agents/workflows/loops/nightly-audit.md +71 -0
- package/.agents/workflows/loops/watch-ci.md +68 -0
- package/docs/CHANGELOG.md +51 -0
- package/package.json +1 -1
- package/.agents/scripts/providers/github/transient-retry.js +0 -62
|
@@ -35,6 +35,11 @@
|
|
|
35
35
|
*/
|
|
36
36
|
|
|
37
37
|
import { AGENT_LABELS } from '../label-constants.js';
|
|
38
|
+
import {
|
|
39
|
+
invalidateProjectMetaCache,
|
|
40
|
+
readProjectMetaCache,
|
|
41
|
+
writeProjectMetaCache,
|
|
42
|
+
} from './project-meta-cache.js';
|
|
38
43
|
import { resolveProjectMeta } from './project-meta-resolver.js';
|
|
39
44
|
|
|
40
45
|
export const LABEL_TO_COLUMN = Object.freeze({
|
|
@@ -74,6 +79,7 @@ export class ColumnSync {
|
|
|
74
79
|
* projectNumber?: number | null,
|
|
75
80
|
* projectOwner?: string | null,
|
|
76
81
|
* logger?: { info: Function, warn: Function },
|
|
82
|
+
* config?: object,
|
|
77
83
|
* ctx?: { provider?: object, config?: { github?: { projectNumber?: number|null } }, logger?: object },
|
|
78
84
|
* }} opts
|
|
79
85
|
*/
|
|
@@ -89,7 +95,16 @@ export class ColumnSync {
|
|
|
89
95
|
null;
|
|
90
96
|
this.projectOwner = opts.projectOwner ?? provider.projectOwner ?? null;
|
|
91
97
|
this.logger = opts.logger ?? ctx?.logger ?? console;
|
|
98
|
+
// Resolved config bag used to locate the on-disk meta cache's tempRoot.
|
|
99
|
+
// Optional — when omitted, the cache resolves the framework-default
|
|
100
|
+
// `temp` root (Story #4252).
|
|
101
|
+
this.config = opts.config ?? ctx?.config ?? undefined;
|
|
92
102
|
this._meta = null; // lazy-cached { projectId, fieldId, options: Map<name, id> }
|
|
103
|
+
// Records whether the in-process `_meta` was hydrated from the on-disk
|
|
104
|
+
// cache, so a GraphQL error against possibly-stale cached metadata can
|
|
105
|
+
// invalidate the disk entry and force a fresh resolve on the next flip
|
|
106
|
+
// (Story #4252).
|
|
107
|
+
this._metaFromDiskCache = false;
|
|
93
108
|
}
|
|
94
109
|
|
|
95
110
|
/**
|
|
@@ -117,8 +132,9 @@ export class ColumnSync {
|
|
|
117
132
|
const itemId = await this.#getProjectItemId(issueId, meta.projectId);
|
|
118
133
|
if (!itemId) return { status: 'skipped', reason: 'not-on-project' };
|
|
119
134
|
|
|
120
|
-
|
|
121
|
-
|
|
135
|
+
try {
|
|
136
|
+
await this.provider.graphql(
|
|
137
|
+
`
|
|
122
138
|
mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $optionId: String!) {
|
|
123
139
|
updateProjectV2ItemFieldValue(
|
|
124
140
|
input: {
|
|
@@ -129,18 +145,71 @@ export class ColumnSync {
|
|
|
129
145
|
}
|
|
130
146
|
) { projectV2Item { id } }
|
|
131
147
|
}`,
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
148
|
+
{
|
|
149
|
+
projectId: meta.projectId,
|
|
150
|
+
itemId,
|
|
151
|
+
fieldId: meta.fieldId,
|
|
152
|
+
optionId,
|
|
153
|
+
},
|
|
154
|
+
);
|
|
155
|
+
} catch (err) {
|
|
156
|
+
// A failed mutation against metadata that came from the disk cache
|
|
157
|
+
// most likely means the board was reconfigured since the entry was
|
|
158
|
+
// written (a stale projectId / fieldId / optionId). Invalidate the
|
|
159
|
+
// disk entry so the next flip re-resolves against the live board and
|
|
160
|
+
// self-heals (Story #4252). Re-throw so the caller's existing error
|
|
161
|
+
// handling (e.g. `syncProjectStatusColumn`'s warn) is preserved.
|
|
162
|
+
this.#invalidateMetaCache();
|
|
163
|
+
throw err;
|
|
164
|
+
}
|
|
139
165
|
return { status: 'synced', column };
|
|
140
166
|
}
|
|
141
167
|
|
|
168
|
+
/**
|
|
169
|
+
* The `(owner, projectNumber)` pair the disk cache is keyed by. Mirrors
|
|
170
|
+
* the owner that `#loadMeta` resolves the board against so a cache hit and
|
|
171
|
+
* a live resolve agree on the same board identity.
|
|
172
|
+
*
|
|
173
|
+
* @returns {{ owner: string|null, projectNumber: number|null }}
|
|
174
|
+
*/
|
|
175
|
+
get #cacheBoard() {
|
|
176
|
+
return {
|
|
177
|
+
owner: this.projectOwner ?? this.provider.owner ?? null,
|
|
178
|
+
projectNumber: this.projectNumber,
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Invalidate the on-disk metadata cache entry for this board and drop the
|
|
184
|
+
* in-process copy, so the next `#loadMeta` re-resolves from the live
|
|
185
|
+
* board. Only fires when the current `_meta` came from the disk cache —
|
|
186
|
+
* a freshly-resolved entry that fails the mutation is a transient/live
|
|
187
|
+
* problem, not a stale-cache problem.
|
|
188
|
+
*/
|
|
189
|
+
#invalidateMetaCache() {
|
|
190
|
+
if (!this._metaFromDiskCache) return;
|
|
191
|
+
const { owner, projectNumber } = this.#cacheBoard;
|
|
192
|
+
invalidateProjectMetaCache({ owner, projectNumber, config: this.config });
|
|
193
|
+
this._meta = null;
|
|
194
|
+
this._metaFromDiskCache = false;
|
|
195
|
+
}
|
|
196
|
+
|
|
142
197
|
async #loadMeta() {
|
|
143
198
|
if (this._meta !== null) return this._meta || null;
|
|
199
|
+
// Disk cache hit short-circuits the ~2 metadata GraphQL round-trips
|
|
200
|
+
// (resolveProjectMeta) — repo-invariant board metadata persists across
|
|
201
|
+
// the cold CLI processes of a single-story delivery (Story #4252).
|
|
202
|
+
const cachedBoard = this.#cacheBoard;
|
|
203
|
+
const cached = readProjectMetaCache({
|
|
204
|
+
owner: cachedBoard.owner,
|
|
205
|
+
projectNumber: cachedBoard.projectNumber,
|
|
206
|
+
config: this.config,
|
|
207
|
+
});
|
|
208
|
+
if (cached) {
|
|
209
|
+
this._meta = cached;
|
|
210
|
+
this._metaFromDiskCache = true;
|
|
211
|
+
return this._meta;
|
|
212
|
+
}
|
|
144
213
|
try {
|
|
145
214
|
// Resolve the board by walking the owner-type ladder
|
|
146
215
|
// (organization → user → viewer) via the shared resolver so the
|
|
@@ -176,6 +245,16 @@ export class ColumnSync {
|
|
|
176
245
|
fieldId: field.id,
|
|
177
246
|
options,
|
|
178
247
|
};
|
|
248
|
+
// Persist the freshly-resolved, repo-invariant metadata so the next
|
|
249
|
+
// cold flip reads it from disk instead of re-paying the resolve
|
|
250
|
+
// (Story #4252). Best-effort: a write failure never blocks the sync.
|
|
251
|
+
writeProjectMetaCache({
|
|
252
|
+
owner: cachedBoard.owner,
|
|
253
|
+
projectNumber: cachedBoard.projectNumber,
|
|
254
|
+
meta: this._meta,
|
|
255
|
+
config: this.config,
|
|
256
|
+
});
|
|
257
|
+
this._metaFromDiskCache = false;
|
|
179
258
|
return this._meta;
|
|
180
259
|
} catch (err) {
|
|
181
260
|
this.logger.warn?.(
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* emit-loop-tick.js — Story #4287 (Epic #4284).
|
|
3
|
+
*
|
|
4
|
+
* Programmatic helper that emits a single `loop.tick` lifecycle event
|
|
5
|
+
* THROUGH the lifecycle bus so a host-driven loop (e.g. a `/loop`-style
|
|
6
|
+
* recurring command or a long-running poll) lands a per-pass record in
|
|
7
|
+
* the on-disk ledger the `/deliver` idle watchdog already scans. The
|
|
8
|
+
* record is what keeps a host loop from running silently: each round
|
|
9
|
+
* appends an inspectable `emitted` line a reconciler can read for
|
|
10
|
+
* forward-progress evidence.
|
|
11
|
+
*
|
|
12
|
+
* Distinct from `story.heartbeat` (emit-story-heartbeat.js): the
|
|
13
|
+
* heartbeat carries Story-phase info for a single in-flight Story and is
|
|
14
|
+
* always Epic-scoped (its ledger path is `epicLedgerPath(epicId)`). A
|
|
15
|
+
* host loop is not bound to a Story tier, so `loop.tick` carries a
|
|
16
|
+
* free-form `loopName`, a monotonic `round` counter, the loop's
|
|
17
|
+
* configured `cadence` label, and a per-round `status` instead. Keeping
|
|
18
|
+
* the two events separate means a loop tick never masquerades as Story
|
|
19
|
+
* progress (and vice versa).
|
|
20
|
+
*
|
|
21
|
+
* Bus path (Story acceptance: "Emitting a loop.tick event THROUGH the
|
|
22
|
+
* lifecycle bus appends a record to the per-run ledger"): this helper
|
|
23
|
+
* constructs a `Bus`, registers a `LedgerWriter` against it, and calls
|
|
24
|
+
* `bus.emit('loop.tick', payload)`. The bus validates the payload against
|
|
25
|
+
* `loop.tick.schema.json` before any listener runs, and the
|
|
26
|
+
* LedgerWriter's privileged `onEmitted` hook lands the `emitted` record
|
|
27
|
+
* on disk — exactly the same persistence path every other lifecycle
|
|
28
|
+
* event flows through. The helper does NOT bypass the bus with a direct
|
|
29
|
+
* `appendFileSync`; routing through the bus is what gives the record its
|
|
30
|
+
* schema-validated, seqId-stamped guarantee.
|
|
31
|
+
*
|
|
32
|
+
* Schema contract (loop.tick.schema.json):
|
|
33
|
+
* { event, loopName, round, cadence, status, timestamp }
|
|
34
|
+
*
|
|
35
|
+
* The schema declares `additionalProperties: false`, so this emitter's
|
|
36
|
+
* signature is deliberately narrow: only the schema-allowed fields are
|
|
37
|
+
* accepted. `status` is one of running|done|blocked.
|
|
38
|
+
*
|
|
39
|
+
* Ledger path resolution: a caller supplies EITHER an explicit
|
|
40
|
+
* `ledgerPath` (the host-loop case — the loop owns where its ledger
|
|
41
|
+
* lives) OR an `epicId`, in which case the canonical
|
|
42
|
+
* `epicLedgerPath(epicId)` is used so an Epic-scoped loop's ticks land
|
|
43
|
+
* in the same `temp/epic-<id>/lifecycle.ndjson` the rest of the run
|
|
44
|
+
* reads. Exactly one of the two MUST be supplied.
|
|
45
|
+
*/
|
|
46
|
+
|
|
47
|
+
import path from 'node:path';
|
|
48
|
+
import { fileURLToPath } from 'node:url';
|
|
49
|
+
|
|
50
|
+
import { epicLedgerPath } from '../../config/temp-paths.js';
|
|
51
|
+
import { createBus } from './bus.js';
|
|
52
|
+
import { createLedgerWriter } from './ledger-writer.js';
|
|
53
|
+
|
|
54
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
55
|
+
const SCHEMA_DIR = path.resolve(
|
|
56
|
+
__dirname,
|
|
57
|
+
'..',
|
|
58
|
+
'..',
|
|
59
|
+
'..',
|
|
60
|
+
'..',
|
|
61
|
+
'schemas',
|
|
62
|
+
'lifecycle',
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
const VALID_STATUSES = new Set(['running', 'done', 'blocked']);
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Parse `temp/epic-<id>/lifecycle.ndjson` (or any
|
|
69
|
+
* `<dir>/epic-<id>/lifecycle.ndjson`) back into `{ tempRoot, epicId }`
|
|
70
|
+
* so a LedgerWriter — which is constructed from `{ epicId, tempRoot }`
|
|
71
|
+
* rather than a raw path — can be bound to the supplied ledger path.
|
|
72
|
+
*
|
|
73
|
+
* The LedgerWriter intentionally re-derives the ledger path from its
|
|
74
|
+
* `tempRoot` + `epicId` (so it can recreate the directory if a listener
|
|
75
|
+
* moves it mid-run), so we decompose the path the caller gave us into
|
|
76
|
+
* those two parts here.
|
|
77
|
+
*
|
|
78
|
+
* @param {string} ledgerPath
|
|
79
|
+
* @returns {{ tempRoot: string, epicId: number }}
|
|
80
|
+
*/
|
|
81
|
+
function decomposeLedgerPath(ledgerPath) {
|
|
82
|
+
const epicDir = path.dirname(ledgerPath);
|
|
83
|
+
const tempRoot = path.dirname(epicDir);
|
|
84
|
+
const epicDirName = path.basename(epicDir);
|
|
85
|
+
const m = /^epic-(\d+)$/.exec(epicDirName);
|
|
86
|
+
if (!m) {
|
|
87
|
+
throw new Error(
|
|
88
|
+
`emitLoopTick: ledgerPath does not match <tempRoot>/epic-<id>/lifecycle.ndjson layout (got ${ledgerPath})`,
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
const epicId = Number.parseInt(m[1], 10);
|
|
92
|
+
return { tempRoot, epicId };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Emit exactly one `loop.tick` event through the lifecycle bus, landing
|
|
97
|
+
* an `emitted` (and `completed`) NDJSON record in the resolved ledger.
|
|
98
|
+
*
|
|
99
|
+
* @param {object} opts
|
|
100
|
+
* @param {string} opts.loopName Free-form loop identifier (non-empty).
|
|
101
|
+
* @param {number} opts.round Monotonic pass counter (integer >= 0).
|
|
102
|
+
* @param {string} opts.cadence Configured interval label, e.g. '5m'.
|
|
103
|
+
* @param {string} [opts.status='running']
|
|
104
|
+
* One of running|done|blocked.
|
|
105
|
+
* @param {string} [opts.timestamp] ISO-8601 wall clock. Defaults to now().
|
|
106
|
+
* @param {number} [opts.epicId] When supplied (and no `ledgerPath`),
|
|
107
|
+
* the canonical `epicLedgerPath(epicId)`
|
|
108
|
+
* is used for the ledger.
|
|
109
|
+
* @param {object} [opts.config] Optional resolved config for tempRoot
|
|
110
|
+
* (only consulted on the `epicId` path).
|
|
111
|
+
* @param {string} [opts.ledgerPath] Explicit ledger path (host-loop case).
|
|
112
|
+
* Mutually exclusive with `epicId`.
|
|
113
|
+
* @returns {Promise<{ ledgerPath: string, payload: object, seqId: number }>}
|
|
114
|
+
*/
|
|
115
|
+
export async function emitLoopTick(opts) {
|
|
116
|
+
const {
|
|
117
|
+
loopName,
|
|
118
|
+
round,
|
|
119
|
+
cadence,
|
|
120
|
+
status = 'running',
|
|
121
|
+
timestamp = new Date().toISOString(),
|
|
122
|
+
epicId,
|
|
123
|
+
config,
|
|
124
|
+
ledgerPath: ledgerPathOverride,
|
|
125
|
+
} = opts ?? {};
|
|
126
|
+
|
|
127
|
+
if (typeof loopName !== 'string' || loopName.length === 0) {
|
|
128
|
+
throw new Error('emitLoopTick: loopName must be a non-empty string');
|
|
129
|
+
}
|
|
130
|
+
if (!Number.isInteger(round) || round < 0) {
|
|
131
|
+
throw new Error('emitLoopTick: round must be a non-negative integer');
|
|
132
|
+
}
|
|
133
|
+
if (typeof cadence !== 'string' || cadence.length === 0) {
|
|
134
|
+
throw new Error('emitLoopTick: cadence must be a non-empty string');
|
|
135
|
+
}
|
|
136
|
+
if (!VALID_STATUSES.has(status)) {
|
|
137
|
+
throw new Error(
|
|
138
|
+
`emitLoopTick: status "${status}" must be one of: ${[...VALID_STATUSES].join(', ')}`,
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const hasEpicId = epicId !== undefined;
|
|
143
|
+
const hasLedgerPath = ledgerPathOverride !== undefined;
|
|
144
|
+
if (hasEpicId === hasLedgerPath) {
|
|
145
|
+
throw new Error('emitLoopTick: supply exactly one of epicId or ledgerPath');
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
let ledgerPath;
|
|
149
|
+
if (hasLedgerPath) {
|
|
150
|
+
if (
|
|
151
|
+
typeof ledgerPathOverride !== 'string' ||
|
|
152
|
+
ledgerPathOverride.length === 0
|
|
153
|
+
) {
|
|
154
|
+
throw new Error('emitLoopTick: ledgerPath must be a non-empty string');
|
|
155
|
+
}
|
|
156
|
+
ledgerPath = ledgerPathOverride;
|
|
157
|
+
} else {
|
|
158
|
+
if (!Number.isInteger(epicId) || epicId < 1) {
|
|
159
|
+
throw new Error('emitLoopTick: epicId must be a positive integer');
|
|
160
|
+
}
|
|
161
|
+
ledgerPath = epicLedgerPath(epicId, config);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const payload = {
|
|
165
|
+
event: 'loop.tick',
|
|
166
|
+
loopName,
|
|
167
|
+
round,
|
|
168
|
+
cadence,
|
|
169
|
+
status,
|
|
170
|
+
timestamp,
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
// Route through the bus so the payload is schema-validated and the
|
|
174
|
+
// LedgerWriter's privileged onEmitted hook persists the record — the
|
|
175
|
+
// same path every lifecycle event flows through.
|
|
176
|
+
const { tempRoot, epicId: ledgerEpicId } = decomposeLedgerPath(ledgerPath);
|
|
177
|
+
const bus = createBus({ schemaDir: SCHEMA_DIR });
|
|
178
|
+
const writer = createLedgerWriter({ epicId: ledgerEpicId, tempRoot });
|
|
179
|
+
writer.register(bus);
|
|
180
|
+
|
|
181
|
+
const { seqId } = await bus.emit('loop.tick', payload);
|
|
182
|
+
return { ledgerPath: writer.ledgerPath, payload, seqId };
|
|
183
|
+
}
|
|
@@ -54,6 +54,8 @@
|
|
|
54
54
|
|
|
55
55
|
import { spawnSync } from 'node:child_process';
|
|
56
56
|
|
|
57
|
+
import { resolveAutoMergeArmCwd } from '../../auto-merge-cwd.js';
|
|
58
|
+
|
|
57
59
|
/**
|
|
58
60
|
* Default `gh pr view --json autoMergeRequest` probe. Pure-spawn helper
|
|
59
61
|
* — exported so tests can stub the shell-out without touching the
|
|
@@ -77,12 +79,28 @@ export function ghPrViewAutoMerge({ prUrl, cwd, spawnFn = spawnSync }) {
|
|
|
77
79
|
* helper. Exported so tests can stub. The arg list is captured in a
|
|
78
80
|
* single helper so the merge-lockout lint allow-list narrows to one
|
|
79
81
|
* literal site.
|
|
82
|
+
*
|
|
83
|
+
* Story #4282: `--delete-branch` makes `gh` shell out to local `git`
|
|
84
|
+
* (including a `git checkout <base>`). When this arm runs from a per-Story
|
|
85
|
+
* worktree cwd checked out on the head branch while the base branch is
|
|
86
|
+
* occupied by the primary worktree, that checkout collides
|
|
87
|
+
* (`fatal: '<base>' is already used by worktree`). We re-point the spawn
|
|
88
|
+
* cwd at the primary worktree root (which holds the base branch) via
|
|
89
|
+
* `resolveAutoMergeArmCwd`, so the local checkout is a no-op while
|
|
90
|
+
* `--delete-branch` (head-branch-removed-on-merge) is preserved. The
|
|
91
|
+
* resolver is non-fatal — it degrades to the original cwd.
|
|
80
92
|
*/
|
|
81
|
-
export function ghPrMergeAuto({
|
|
93
|
+
export function ghPrMergeAuto({
|
|
94
|
+
prUrl,
|
|
95
|
+
cwd,
|
|
96
|
+
spawnFn = spawnSync,
|
|
97
|
+
resolveArmCwd = resolveAutoMergeArmCwd,
|
|
98
|
+
}) {
|
|
99
|
+
const armCwd = resolveArmCwd(cwd);
|
|
82
100
|
const result = spawnFn(
|
|
83
101
|
'gh',
|
|
84
102
|
['pr', 'merge', prUrl, '--auto', '--squash', '--delete-branch'],
|
|
85
|
-
{ cwd, encoding: 'utf-8', shell: false },
|
|
103
|
+
{ cwd: armCwd, encoding: 'utf-8', shell: false },
|
|
86
104
|
);
|
|
87
105
|
return {
|
|
88
106
|
status: result.status ?? 1,
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* project-meta-cache — on-disk cache for repo-invariant GitHub Projects v2
|
|
3
|
+
* board metadata (Story #4252).
|
|
4
|
+
*
|
|
5
|
+
* Every `agent::*` label flip routes through `transitionTicketState` →
|
|
6
|
+
* `ColumnSync.sync`, which must resolve the board's `{ projectId, fieldId,
|
|
7
|
+
* options }` before issuing the Status mutation. Single-story delivery
|
|
8
|
+
* performs each flip in a *separate cold CLI process* (init → executing,
|
|
9
|
+
* close → closing, confirm-merge → done, plus `resync-status-column`), so
|
|
10
|
+
* the in-process `_meta` cache is always empty and every flip re-pays
|
|
11
|
+
* `resolveProjectMeta` + an item-id lookup + the mutation (~3 GraphQL
|
|
12
|
+
* round-trips).
|
|
13
|
+
*
|
|
14
|
+
* The board metadata is **repo-invariant** — it changes only when the
|
|
15
|
+
* Status single-select field is reconfigured — so it is a prime candidate
|
|
16
|
+
* for an on-disk cache that survives across the cold processes. This module
|
|
17
|
+
* persists the resolved metadata to a small JSON file under the resolved
|
|
18
|
+
* `tempRoot`, keyed by `owner/projectNumber`, carrying a TTL. Correctness
|
|
19
|
+
* is bounded by "the mutation still validates against the live board": a
|
|
20
|
+
* stale entry at worst causes one failed mutation, which the caller treats
|
|
21
|
+
* as a signal to invalidate the entry and re-resolve — never a wrong write.
|
|
22
|
+
*
|
|
23
|
+
* Layout: `<tempRoot>/cache/project-meta.json`, a single JSON object keyed
|
|
24
|
+
* by `<owner>/<projectNumber>`:
|
|
25
|
+
*
|
|
26
|
+
* {
|
|
27
|
+
* "dsj1984/1": {
|
|
28
|
+
* "cachedAt": 1718000000000,
|
|
29
|
+
* "projectId": "PVT_…",
|
|
30
|
+
* "fieldId": "PVTSSF_…",
|
|
31
|
+
* "options": { "Todo": "abc", "In Progress": "def", "Done": "ghi" }
|
|
32
|
+
* }
|
|
33
|
+
* }
|
|
34
|
+
*
|
|
35
|
+
* The `options` map is serialised as a plain object and re-hydrated into a
|
|
36
|
+
* `Map<name, id>` on read so the ColumnSync call site is identical whether
|
|
37
|
+
* the metadata came from the disk cache or a live resolve.
|
|
38
|
+
*
|
|
39
|
+
* The cache file lives under `tempRoot`, which is gitignored
|
|
40
|
+
* (`temp/` in `.gitignore`), so it introduces no new tracked artifact.
|
|
41
|
+
*/
|
|
42
|
+
|
|
43
|
+
import fs from 'node:fs';
|
|
44
|
+
import path from 'node:path';
|
|
45
|
+
import { anchorTempRoot, tempRootFrom } from '../config/temp-paths.js';
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Default time-to-live for a cached board-metadata entry, in milliseconds.
|
|
49
|
+
* Board metadata is repo-invariant (it only changes on a manual Status
|
|
50
|
+
* field reconfiguration), so a generous TTL is safe — and a stale entry is
|
|
51
|
+
* self-healing via invalidate-on-error regardless. One hour comfortably
|
|
52
|
+
* spans a single `/single-story-deliver` run's four cold flips while still
|
|
53
|
+
* forcing a periodic re-resolve.
|
|
54
|
+
*/
|
|
55
|
+
const DEFAULT_META_TTL_MS = 60 * 60 * 1000;
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Resolve the path to the on-disk project-meta cache file under the
|
|
59
|
+
* (anchored) resolved `tempRoot`. A relative `tempRoot` is anchored to the
|
|
60
|
+
* main checkout root so a worktree child and the main-checkout host
|
|
61
|
+
* converge on the same file (Story #3900 semantics, reused here).
|
|
62
|
+
*
|
|
63
|
+
* @param {object} [config] Optional resolved config bag. Omitted → framework
|
|
64
|
+
* default (`temp`).
|
|
65
|
+
* @returns {string}
|
|
66
|
+
*/
|
|
67
|
+
function projectMetaCachePath(config) {
|
|
68
|
+
return path.join(
|
|
69
|
+
anchorTempRoot(tempRootFrom(config)),
|
|
70
|
+
'cache',
|
|
71
|
+
'project-meta.json',
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Build the per-board cache key from `(owner, projectNumber)`.
|
|
77
|
+
*
|
|
78
|
+
* @param {string|null|undefined} owner
|
|
79
|
+
* @param {number|string|null|undefined} projectNumber
|
|
80
|
+
* @returns {string|null} `<owner>/<projectNumber>`, or null when either part
|
|
81
|
+
* is missing (an un-keyable board — caller skips the cache).
|
|
82
|
+
*/
|
|
83
|
+
function projectMetaCacheKey(owner, projectNumber) {
|
|
84
|
+
if (!owner || projectNumber === null || projectNumber === undefined) {
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
return `${owner}/${projectNumber}`;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Read the entire cache file as a plain object. Returns an empty object on
|
|
92
|
+
* any read/parse failure (missing file, malformed JSON) so the caller treats
|
|
93
|
+
* a corrupt cache as a cold miss rather than throwing.
|
|
94
|
+
*
|
|
95
|
+
* @param {string} filePath
|
|
96
|
+
* @returns {Record<string, object>}
|
|
97
|
+
*/
|
|
98
|
+
function readCacheFile(filePath) {
|
|
99
|
+
try {
|
|
100
|
+
const raw = fs.readFileSync(filePath, 'utf8');
|
|
101
|
+
const parsed = JSON.parse(raw);
|
|
102
|
+
return parsed && typeof parsed === 'object' ? parsed : {};
|
|
103
|
+
} catch {
|
|
104
|
+
return {};
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Read a cached metadata entry for `(owner, projectNumber)`.
|
|
110
|
+
*
|
|
111
|
+
* Returns the re-hydrated `{ projectId, fieldId, options: Map }` descriptor
|
|
112
|
+
* when a fresh (within TTL) entry exists, or `null` on a miss / expired /
|
|
113
|
+
* malformed entry. Never throws — a read failure is a cache miss.
|
|
114
|
+
*
|
|
115
|
+
* @param {{
|
|
116
|
+
* owner?: string|null,
|
|
117
|
+
* projectNumber?: number|string|null,
|
|
118
|
+
* config?: object,
|
|
119
|
+
* ttlMs?: number,
|
|
120
|
+
* now?: number,
|
|
121
|
+
* }} opts
|
|
122
|
+
* @returns {{ projectId: string, fieldId: string, options: Map<string,string> } | null}
|
|
123
|
+
*/
|
|
124
|
+
export function readProjectMetaCache(opts = {}) {
|
|
125
|
+
const key = projectMetaCacheKey(opts.owner, opts.projectNumber);
|
|
126
|
+
if (!key) return null;
|
|
127
|
+
const ttlMs = opts.ttlMs ?? DEFAULT_META_TTL_MS;
|
|
128
|
+
const now = opts.now ?? Date.now();
|
|
129
|
+
|
|
130
|
+
const filePath = projectMetaCachePath(opts.config);
|
|
131
|
+
const store = readCacheFile(filePath);
|
|
132
|
+
const entry = store[key];
|
|
133
|
+
if (!entry || typeof entry !== 'object') return null;
|
|
134
|
+
|
|
135
|
+
const { cachedAt, projectId, fieldId, options } = entry;
|
|
136
|
+
if (
|
|
137
|
+
typeof cachedAt !== 'number' ||
|
|
138
|
+
typeof projectId !== 'string' ||
|
|
139
|
+
typeof fieldId !== 'string' ||
|
|
140
|
+
!options ||
|
|
141
|
+
typeof options !== 'object'
|
|
142
|
+
) {
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
// TTL check — an expired entry is a miss so the caller re-resolves.
|
|
146
|
+
if (now - cachedAt > ttlMs) return null;
|
|
147
|
+
|
|
148
|
+
return {
|
|
149
|
+
projectId,
|
|
150
|
+
fieldId,
|
|
151
|
+
options: new Map(Object.entries(options)),
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Persist a resolved metadata descriptor for `(owner, projectNumber)`.
|
|
157
|
+
*
|
|
158
|
+
* The `options` value may be a `Map<name,id>` or a plain object; it is
|
|
159
|
+
* normalised to a plain object on disk. Best-effort: any write failure is
|
|
160
|
+
* swallowed (the cache is a pure optimisation — a failed write just means
|
|
161
|
+
* the next process re-resolves). Writes the rest of the store back intact so
|
|
162
|
+
* sibling board entries are preserved.
|
|
163
|
+
*
|
|
164
|
+
* @param {{
|
|
165
|
+
* owner?: string|null,
|
|
166
|
+
* projectNumber?: number|string|null,
|
|
167
|
+
* meta: { projectId: string, fieldId: string, options: Map<string,string>|Record<string,string> },
|
|
168
|
+
* config?: object,
|
|
169
|
+
* now?: number,
|
|
170
|
+
* }} opts
|
|
171
|
+
* @returns {boolean} true when the entry was written, false on a skip/failure.
|
|
172
|
+
*/
|
|
173
|
+
export function writeProjectMetaCache(opts = {}) {
|
|
174
|
+
const key = projectMetaCacheKey(opts.owner, opts.projectNumber);
|
|
175
|
+
if (!key) return false;
|
|
176
|
+
const meta = opts.meta;
|
|
177
|
+
if (
|
|
178
|
+
!meta ||
|
|
179
|
+
typeof meta.projectId !== 'string' ||
|
|
180
|
+
typeof meta.fieldId !== 'string'
|
|
181
|
+
) {
|
|
182
|
+
return false;
|
|
183
|
+
}
|
|
184
|
+
const now = opts.now ?? Date.now();
|
|
185
|
+
|
|
186
|
+
const options =
|
|
187
|
+
meta.options instanceof Map
|
|
188
|
+
? Object.fromEntries(meta.options)
|
|
189
|
+
: { ...(meta.options ?? {}) };
|
|
190
|
+
|
|
191
|
+
const filePath = projectMetaCachePath(opts.config);
|
|
192
|
+
try {
|
|
193
|
+
const store = readCacheFile(filePath);
|
|
194
|
+
store[key] = {
|
|
195
|
+
cachedAt: now,
|
|
196
|
+
projectId: meta.projectId,
|
|
197
|
+
fieldId: meta.fieldId,
|
|
198
|
+
options,
|
|
199
|
+
};
|
|
200
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
201
|
+
fs.writeFileSync(filePath, `${JSON.stringify(store, null, 2)}\n`, 'utf8');
|
|
202
|
+
return true;
|
|
203
|
+
} catch {
|
|
204
|
+
return false;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Invalidate (delete) the cached entry for `(owner, projectNumber)`.
|
|
210
|
+
*
|
|
211
|
+
* Called when a GraphQL error fires against a board whose metadata came from
|
|
212
|
+
* the cache, so a mid-run board reconfiguration self-heals on the next flip
|
|
213
|
+
* (which re-resolves and re-writes). Best-effort and idempotent: a missing
|
|
214
|
+
* entry or unreadable file is a no-op. Returns true when an entry was
|
|
215
|
+
* actually removed.
|
|
216
|
+
*
|
|
217
|
+
* @param {{
|
|
218
|
+
* owner?: string|null,
|
|
219
|
+
* projectNumber?: number|string|null,
|
|
220
|
+
* config?: object,
|
|
221
|
+
* }} opts
|
|
222
|
+
* @returns {boolean}
|
|
223
|
+
*/
|
|
224
|
+
export function invalidateProjectMetaCache(opts = {}) {
|
|
225
|
+
const key = projectMetaCacheKey(opts.owner, opts.projectNumber);
|
|
226
|
+
if (!key) return false;
|
|
227
|
+
const filePath = projectMetaCachePath(opts.config);
|
|
228
|
+
try {
|
|
229
|
+
const store = readCacheFile(filePath);
|
|
230
|
+
if (!(key in store)) return false;
|
|
231
|
+
delete store[key];
|
|
232
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
233
|
+
fs.writeFileSync(filePath, `${JSON.stringify(store, null, 2)}\n`, 'utf8');
|
|
234
|
+
return true;
|
|
235
|
+
} catch {
|
|
236
|
+
return false;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
@@ -89,6 +89,7 @@ function defaultSleep(ms) {
|
|
|
89
89
|
* pollAttempts?: number,
|
|
90
90
|
* pollDelayMs?: number,
|
|
91
91
|
* sleepFn?: (ms: number) => Promise<void>,
|
|
92
|
+
* config?: object,
|
|
92
93
|
* }} args
|
|
93
94
|
* @returns {Promise<{ status: string, column?: string, reason?: string, attempts?: number }>}
|
|
94
95
|
*/
|
|
@@ -100,6 +101,7 @@ export async function reassertStatusColumn(args) {
|
|
|
100
101
|
pollAttempts = DEFAULT_POLL_ATTEMPTS,
|
|
101
102
|
pollDelayMs = DEFAULT_POLL_DELAY_MS,
|
|
102
103
|
sleepFn = defaultSleep,
|
|
104
|
+
config,
|
|
103
105
|
} = args ?? {};
|
|
104
106
|
if (!provider || typeof provider.getTicket !== 'function') {
|
|
105
107
|
throw new TypeError(
|
|
@@ -128,7 +130,7 @@ export async function reassertStatusColumn(args) {
|
|
|
128
130
|
if (!targetColumn) {
|
|
129
131
|
return { status: 'skipped', reason: 'no-matching-label' };
|
|
130
132
|
}
|
|
131
|
-
const sync = new ColumnSync({ provider, logger: logger ?? console });
|
|
133
|
+
const sync = new ColumnSync({ provider, logger: logger ?? console, config });
|
|
132
134
|
|
|
133
135
|
// First attempt — always fires through ColumnSync.sync so the skip
|
|
134
136
|
// paths (no-project / no-meta / no-option-<col> / not-on-project)
|
|
@@ -12,9 +12,21 @@
|
|
|
12
12
|
* inject a synchronous fake; the default runner delegates to
|
|
13
13
|
* `gh.pr.merge`, which spawns through the classified, typed-error
|
|
14
14
|
* surface instead of a raw `execFileSync('gh', …)` call.
|
|
15
|
+
*
|
|
16
|
+
* Story #4282 made arming robust when the base branch is checked out by a
|
|
17
|
+
* git worktree. The `--delete-branch` flag makes `gh` shell out to local
|
|
18
|
+
* `git` (including a `git checkout <base>`); from a per-Story worktree cwd
|
|
19
|
+
* that collides with the base branch already checked out by the primary
|
|
20
|
+
* worktree (`fatal: '<base>' is already used by worktree`). We now resolve
|
|
21
|
+
* the arm cwd to the **primary worktree root** (which holds the base
|
|
22
|
+
* branch) via `resolveAutoMergeArmCwd`, so `gh`'s local checkout is a
|
|
23
|
+
* no-op. `--delete-branch` is preserved verbatim, so the PR head branch is
|
|
24
|
+
* still deleted on merge without depending on the repo's auto-delete
|
|
25
|
+
* setting. Resolution is non-fatal — it degrades to the original cwd.
|
|
15
26
|
*/
|
|
16
27
|
|
|
17
28
|
import { gh as defaultGh } from '../../../gh-exec.js';
|
|
29
|
+
import { resolveAutoMergeArmCwd } from '../../auto-merge-cwd.js';
|
|
18
30
|
|
|
19
31
|
/**
|
|
20
32
|
* Enable GitHub native auto-merge on the PR. Non-fatal.
|
|
@@ -24,11 +36,22 @@ import { gh as defaultGh } from '../../../gh-exec.js';
|
|
|
24
36
|
* prNumber: number,
|
|
25
37
|
* gh?: ReturnType<typeof import('../../../gh-exec.js').createGh>,
|
|
26
38
|
* runner?: (args: string[], opts: object) => ({ status: number, stdout?: string, stderr?: string } | Promise<{ status: number, stdout?: string, stderr?: string }>),
|
|
39
|
+
* resolveArmCwd?: (cwd: string) => string,
|
|
27
40
|
* }} opts
|
|
28
41
|
* @returns {Promise<{ enabled: boolean, reason?: string }>}
|
|
29
42
|
*/
|
|
30
|
-
export async function enableAutoMergeWith({
|
|
43
|
+
export async function enableAutoMergeWith({
|
|
44
|
+
cwd,
|
|
45
|
+
prNumber,
|
|
46
|
+
gh,
|
|
47
|
+
runner,
|
|
48
|
+
resolveArmCwd = resolveAutoMergeArmCwd,
|
|
49
|
+
}) {
|
|
31
50
|
const exec = runner ?? makeDefaultGhAutoMergeRunner(gh ?? defaultGh);
|
|
51
|
+
// Re-point the arm at the base-branch (primary) worktree so gh's
|
|
52
|
+
// `--delete-branch` local `git checkout <base>` cannot collide with the
|
|
53
|
+
// base branch already checked out by the primary worktree (Story #4282).
|
|
54
|
+
const armCwd = resolveArmCwd(cwd);
|
|
32
55
|
try {
|
|
33
56
|
const result = await exec(
|
|
34
57
|
[
|
|
@@ -39,7 +62,7 @@ export async function enableAutoMergeWith({ cwd, prNumber, gh, runner }) {
|
|
|
39
62
|
'--squash',
|
|
40
63
|
'--delete-branch',
|
|
41
64
|
],
|
|
42
|
-
{ cwd },
|
|
65
|
+
{ cwd: armCwd },
|
|
43
66
|
);
|
|
44
67
|
if (result.status === 0) return { enabled: true };
|
|
45
68
|
return {
|