@ikunin/sprintpilot 2.2.12 → 2.2.14
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.
|
@@ -695,6 +695,17 @@ function composeRuntimeState(persisted, profile, projectRoot) {
|
|
|
695
695
|
// across halts so the next session re-emits the prompt rather than
|
|
696
696
|
// silently dropping the LLM's proposal.
|
|
697
697
|
pending_alternative: persisted.pending_alternative || null,
|
|
698
|
+
// session_story_limit counter: per-session count of stories completed.
|
|
699
|
+
// adapt.advanceState increments on STORY_DONE → EPIC_BOUNDARY_CHECK;
|
|
700
|
+
// state-machine.nextAction emits a halt when this hits profile.session_story_limit.
|
|
701
|
+
// cmdStart resets to 0 on each new session boot (the limit is per-session,
|
|
702
|
+
// not lifetime). Persisted across in-session resumes so a `pause` mid-flow
|
|
703
|
+
// doesn't reset progress against the limit.
|
|
704
|
+
session_stories_completed: persisted.session_stories_completed || 0,
|
|
705
|
+
// .autopilot.lock holder ID, persisted so subsequent cmdStart calls
|
|
706
|
+
// recognize their own lock and refresh in place. Cleared by
|
|
707
|
+
// sprint-autopilot-off (which calls `lock.js release`).
|
|
708
|
+
lock_session_id: persisted.lock_session_id || null,
|
|
698
709
|
// halt_requested is intentionally NOT carried forward here: cmdStart
|
|
699
710
|
// clears it on each new session (a `pause` cleanly halts THIS session
|
|
700
711
|
// and the next /sprint-autopilot-on resumes normally).
|
|
@@ -723,6 +734,8 @@ function persistRuntimeState(runtime, profile, projectRoot) {
|
|
|
723
734
|
story_queue: Array.isArray(runtime.story_queue) ? runtime.story_queue : [],
|
|
724
735
|
land_pending: runtime.land_pending,
|
|
725
736
|
pending_alternative: runtime.pending_alternative || null,
|
|
737
|
+
session_stories_completed: runtime.session_stories_completed || 0,
|
|
738
|
+
lock_session_id: runtime.lock_session_id || null,
|
|
726
739
|
};
|
|
727
740
|
return persistState(updates, profile, projectRoot, runtime.story_key || 'sprint');
|
|
728
741
|
}
|
|
@@ -1058,6 +1071,96 @@ function lockUserBranchIfNeeded(runtime, profile, projectRoot) {
|
|
|
1058
1071
|
return null;
|
|
1059
1072
|
}
|
|
1060
1073
|
|
|
1074
|
+
// .autopilot.lock: prevent concurrent autopilot sessions on the same
|
|
1075
|
+
// project. Lockfile contract documented in modules/git/config.yaml
|
|
1076
|
+
// ("Lock file (.autopilot.lock — prevents concurrent autopilot sessions)")
|
|
1077
|
+
// and implemented in scripts/lock.js. cmdStart wires it in here.
|
|
1078
|
+
//
|
|
1079
|
+
// Idempotency: a /sprint-autopilot-on mid-flow (e.g. after a halt) must
|
|
1080
|
+
// not refuse to resume just because the prior cmdStart left a lock. We
|
|
1081
|
+
// store the lock's session_id in autopilot-state.yaml on first acquire and
|
|
1082
|
+
// treat a matching id on subsequent cmdStart calls as "my lock; refresh".
|
|
1083
|
+
//
|
|
1084
|
+
// Return shape:
|
|
1085
|
+
// { acquired: true, id, refreshed?: true } — proceed
|
|
1086
|
+
// { acquired: false, holder, ageMin } — halt; caller emits user_prompt
|
|
1087
|
+
// { acquired: true, id, takeover: 'stale' } — stale takeover; proceed
|
|
1088
|
+
function acquireAutopilotLock(persisted, profile, projectRoot) {
|
|
1089
|
+
const { execFileSync: runFile } = require('node:child_process');
|
|
1090
|
+
const lockScript = path.join(projectRoot, '_Sprintpilot', 'scripts', 'lock.js');
|
|
1091
|
+
if (!fs.existsSync(lockScript)) {
|
|
1092
|
+
return { acquired: true, id: null, skipped: true };
|
|
1093
|
+
}
|
|
1094
|
+
const lockFile = path.join(projectRoot, '.autopilot.lock');
|
|
1095
|
+
const stale = typeof profile.lock_stale_timeout_minutes === 'number'
|
|
1096
|
+
? profile.lock_stale_timeout_minutes
|
|
1097
|
+
: 30;
|
|
1098
|
+
// stale_timeout_minutes <= 0 means "never auto-take-over". Pass a very
|
|
1099
|
+
// large value to lock.js so it never deems anything STALE.
|
|
1100
|
+
const staleArg = stale > 0 ? String(stale) : '999999';
|
|
1101
|
+
|
|
1102
|
+
const callLock = (action) => {
|
|
1103
|
+
try {
|
|
1104
|
+
const out = runFile(
|
|
1105
|
+
'node',
|
|
1106
|
+
[lockScript, action, '--file', lockFile, '--stale-minutes', staleArg],
|
|
1107
|
+
{ encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] },
|
|
1108
|
+
).trim();
|
|
1109
|
+
return { ok: true, out };
|
|
1110
|
+
} catch (e) {
|
|
1111
|
+
return { ok: false, out: (e.stdout && e.stdout.toString().trim()) || '', code: e.status };
|
|
1112
|
+
}
|
|
1113
|
+
};
|
|
1114
|
+
|
|
1115
|
+
const checkResult = callLock('check');
|
|
1116
|
+
const checkOut = checkResult.out || 'FREE';
|
|
1117
|
+
|
|
1118
|
+
if (checkOut === 'FREE') {
|
|
1119
|
+
const acq = callLock('acquire');
|
|
1120
|
+
if (acq.ok && acq.out.startsWith('ACQUIRED:')) {
|
|
1121
|
+
return { acquired: true, id: acq.out.slice('ACQUIRED:'.length) };
|
|
1122
|
+
}
|
|
1123
|
+
// Race: another acquirer just created the lock. Fall through to retry.
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
const match = /^(LOCKED|STALE):([^:]+):(\d+)m$/.exec(checkOut);
|
|
1127
|
+
if (match) {
|
|
1128
|
+
const state = match[1];
|
|
1129
|
+
const holderId = match[2];
|
|
1130
|
+
const ageMin = parseInt(match[3], 10);
|
|
1131
|
+
|
|
1132
|
+
// My own lock? Refresh (rewrite ts + same id) and proceed.
|
|
1133
|
+
if (state === 'LOCKED' && persisted.lock_session_id && persisted.lock_session_id === holderId) {
|
|
1134
|
+
try {
|
|
1135
|
+
const ts = Math.floor(Date.now() / 1000);
|
|
1136
|
+
fs.writeFileSync(lockFile, `${ts}\n${holderId}\n`, { encoding: 'utf8', mode: 0o644 });
|
|
1137
|
+
return { acquired: true, id: holderId, refreshed: true };
|
|
1138
|
+
} catch (e) {
|
|
1139
|
+
return { acquired: false, holder: holderId, ageMin, error: `lock refresh failed: ${e.message}` };
|
|
1140
|
+
}
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
if (state === 'STALE') {
|
|
1144
|
+
const acq = callLock('acquire');
|
|
1145
|
+
if (acq.ok && acq.out.startsWith('ACQUIRED_STALE:')) {
|
|
1146
|
+
return { acquired: true, id: acq.out.slice('ACQUIRED_STALE:'.length), takeover: 'stale' };
|
|
1147
|
+
}
|
|
1148
|
+
if (acq.ok && acq.out.startsWith('ACQUIRED:')) {
|
|
1149
|
+
return { acquired: true, id: acq.out.slice('ACQUIRED:'.length) };
|
|
1150
|
+
}
|
|
1151
|
+
const reMatch = /^LOCKED:([^:]+):(\d+)m$/.exec(acq.out);
|
|
1152
|
+
if (reMatch) {
|
|
1153
|
+
return { acquired: false, holder: reMatch[1], ageMin: parseInt(reMatch[2], 10) };
|
|
1154
|
+
}
|
|
1155
|
+
return { acquired: false, holder: holderId, ageMin };
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
return { acquired: false, holder: holderId, ageMin };
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
return { acquired: true, id: null, warning: `unrecognized lock state: ${checkOut}` };
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1061
1164
|
function cmdStart(opts) {
|
|
1062
1165
|
const projectRoot = resolveProjectRoot(opts);
|
|
1063
1166
|
const { typed: profile } = resolveProfile(projectRoot, opts.profile);
|
|
@@ -1110,6 +1213,49 @@ function cmdStart(opts) {
|
|
|
1110
1213
|
}
|
|
1111
1214
|
}
|
|
1112
1215
|
|
|
1216
|
+
// .autopilot.lock — acquire before any state mutation. If another
|
|
1217
|
+
// session holds the lock (and it isn't ours and isn't stale), bail out
|
|
1218
|
+
// with a user_prompt action so the LLM/user knows to either wait or
|
|
1219
|
+
// run `sprint-autopilot-off` in the other session.
|
|
1220
|
+
const lockOutcome = acquireAutopilotLock(persisted, profile, projectRoot);
|
|
1221
|
+
if (!lockOutcome.acquired) {
|
|
1222
|
+
const haltAction = {
|
|
1223
|
+
type: 'user_prompt',
|
|
1224
|
+
reason: 'autopilot_lock_held',
|
|
1225
|
+
prompt:
|
|
1226
|
+
`Another autopilot session holds .autopilot.lock (session ${lockOutcome.holder}, age ${lockOutcome.ageMin}m). ` +
|
|
1227
|
+
`Wait for it to finish, run \`/sprint-autopilot-off\` in the other session, or delete .autopilot.lock if you're sure the holder crashed.`,
|
|
1228
|
+
holder: lockOutcome.holder,
|
|
1229
|
+
age_minutes: lockOutcome.ageMin,
|
|
1230
|
+
};
|
|
1231
|
+
ledger.append(
|
|
1232
|
+
{ kind: 'action_emitted', phase: persisted.current_bmad_step || null, action: haltAction },
|
|
1233
|
+
{ projectRoot },
|
|
1234
|
+
);
|
|
1235
|
+
process.stdout.write(`${JSON.stringify({ action: haltAction, phase: persisted.current_bmad_step || null }, null, 2)}\n`);
|
|
1236
|
+
return 0;
|
|
1237
|
+
}
|
|
1238
|
+
if (lockOutcome.id) {
|
|
1239
|
+
persisted.lock_session_id = lockOutcome.id;
|
|
1240
|
+
// Eagerly persist lock_session_id so a crash between here and the
|
|
1241
|
+
// final persistRuntimeState below doesn't leave the lockfile owned
|
|
1242
|
+
// by an ID that nothing knows about. Without this, a mid-cmdStart
|
|
1243
|
+
// crash would brick the project until the lock goes stale.
|
|
1244
|
+
persistState({ lock_session_id: lockOutcome.id }, profile, projectRoot, 'sprint');
|
|
1245
|
+
if (profile.coalesce_state_writes) stateStore.flush(profile, { projectRoot, story: 'sprint' });
|
|
1246
|
+
ledger.append(
|
|
1247
|
+
{
|
|
1248
|
+
kind: 'lock_acquired',
|
|
1249
|
+
detail: {
|
|
1250
|
+
session_id: lockOutcome.id,
|
|
1251
|
+
takeover: lockOutcome.takeover || null,
|
|
1252
|
+
refreshed: !!lockOutcome.refreshed,
|
|
1253
|
+
},
|
|
1254
|
+
},
|
|
1255
|
+
{ projectRoot },
|
|
1256
|
+
);
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1113
1259
|
// Persist the new queue BEFORE composing runtime state so the queue
|
|
1114
1260
|
// head is visible to composeRuntimeState's resolver.
|
|
1115
1261
|
if (explicitQueue.length > 0) {
|
|
@@ -1133,6 +1279,12 @@ function cmdStart(opts) {
|
|
|
1133
1279
|
// profile-aware initial phase when persisted state is empty.
|
|
1134
1280
|
const runtime = composeRuntimeState(persisted, profile, projectRoot);
|
|
1135
1281
|
|
|
1282
|
+
// session_story_limit is per-session — a fresh `autopilot start`
|
|
1283
|
+
// resets the counter so the next batch of N stories can run before
|
|
1284
|
+
// the next halt. (state-machine.nextAction enforces the cap; adapt.js
|
|
1285
|
+
// increments on STORY_DONE → EPIC_BOUNDARY_CHECK.)
|
|
1286
|
+
runtime.session_stories_completed = 0;
|
|
1287
|
+
|
|
1136
1288
|
const lockResult = lockUserBranchIfNeeded(runtime, profile, projectRoot);
|
|
1137
1289
|
if (lockResult && lockResult.halt) {
|
|
1138
1290
|
ledger.append(
|
|
@@ -1405,4 +1557,11 @@ if (require.main === module) {
|
|
|
1405
1557
|
process.exit(main(process.argv.slice(2)));
|
|
1406
1558
|
}
|
|
1407
1559
|
|
|
1408
|
-
module.exports = {
|
|
1560
|
+
module.exports = {
|
|
1561
|
+
main,
|
|
1562
|
+
SUBCOMMANDS,
|
|
1563
|
+
decorateGitOp,
|
|
1564
|
+
decorateRunScript,
|
|
1565
|
+
composeRuntimeState,
|
|
1566
|
+
acquireAutopilotLock,
|
|
1567
|
+
};
|
|
@@ -592,6 +592,10 @@ function advanceState(state, profile, newPhase, signal) {
|
|
|
592
592
|
next.story_key = null;
|
|
593
593
|
next.story_file_path = null;
|
|
594
594
|
next.ac_summary = null;
|
|
595
|
+
// session_story_limit: increment per-session completion counter so
|
|
596
|
+
// state-machine.js#nextAction can emit the halt at the next
|
|
597
|
+
// emission. The counter resets on cmdStart (new session boundary).
|
|
598
|
+
next.session_stories_completed = (state.session_stories_completed || 0) + 1;
|
|
595
599
|
}
|
|
596
600
|
|
|
597
601
|
return next;
|
|
@@ -148,6 +148,12 @@ function flatToProfile(resolved, profileName) {
|
|
|
148
148
|
// (story keys + prefix) to this length with a 6-char hash suffix to
|
|
149
149
|
// keep the name unique. Honors the contract advertised in config.yaml.
|
|
150
150
|
max_branch_length: coerceInt(get(resolved, 'git.max_branch_length'), 60),
|
|
151
|
+
// git.lock.stale_timeout_minutes — .autopilot.lock is auto-taken-over
|
|
152
|
+
// by cmdStart when older than this. Documented in modules/git/config.yaml
|
|
153
|
+
// ("auto-remove locks older than this"). Forwarded to lock.js via
|
|
154
|
+
// --stale-minutes. 0 disables the auto-takeover entirely (locks are
|
|
155
|
+
// never considered stale; manual `autopilot off` required).
|
|
156
|
+
lock_stale_timeout_minutes: coerceInt(get(resolved, 'git.lock.stale_timeout_minutes'), 30),
|
|
151
157
|
// git.platform.provider + base_url — forwarded to create-pr.js when
|
|
152
158
|
// the orchestrator opens or polls PRs. 'auto' delegates platform
|
|
153
159
|
// detection to create-pr.js (currently defaults to github).
|
|
@@ -170,6 +170,37 @@ function nextAction(state, profile) {
|
|
|
170
170
|
handoff: 'sprint_finalize_pending',
|
|
171
171
|
};
|
|
172
172
|
}
|
|
173
|
+
// session_story_limit: when this session has completed >= limit
|
|
174
|
+
// stories, halt cleanly. The next /sprint-autopilot-on resets the
|
|
175
|
+
// counter and continues. Skipped when limit === 0 (unlimited per
|
|
176
|
+
// Sprintpilot.md) or limit is unset.
|
|
177
|
+
const sessionLimit = profile && profile.session_story_limit;
|
|
178
|
+
const sessionDone = state.session_stories_completed || 0;
|
|
179
|
+
if (
|
|
180
|
+
typeof sessionLimit === 'number' &&
|
|
181
|
+
sessionLimit > 0 &&
|
|
182
|
+
sessionDone >= sessionLimit &&
|
|
183
|
+
// Don't halt when we're at a story-start phase — that would
|
|
184
|
+
// create an infinite halt loop on resume. The limit check should
|
|
185
|
+
// fire at the boundary between stories (epic_boundary_check or
|
|
186
|
+
// before the next story is picked). Most natural is to halt
|
|
187
|
+
// before emitting the next story-start action.
|
|
188
|
+
(state.phase === STATES.EPIC_BOUNDARY_CHECK ||
|
|
189
|
+
state.phase === STATES.RETROSPECTIVE ||
|
|
190
|
+
state.phase === STATES.PREPARE_STORY_BRANCH ||
|
|
191
|
+
state.phase === STATES.CREATE_STORY ||
|
|
192
|
+
state.phase === STATES.NANO_QUICK_DEV)
|
|
193
|
+
) {
|
|
194
|
+
return {
|
|
195
|
+
type: 'halt',
|
|
196
|
+
reason: 'session_story_limit_reached',
|
|
197
|
+
prompt:
|
|
198
|
+
`Session story limit reached (${sessionDone}/${sessionLimit}). ` +
|
|
199
|
+
`Run /sprint-autopilot-on to start a new session and continue with the next pending story.`,
|
|
200
|
+
session_stories_completed: sessionDone,
|
|
201
|
+
session_story_limit: sessionLimit,
|
|
202
|
+
};
|
|
203
|
+
}
|
|
173
204
|
|
|
174
205
|
switch (state.phase) {
|
|
175
206
|
case STATES.PREPARE_STORY_BRANCH: {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ikunin/sprintpilot",
|
|
3
|
-
"version": "2.2.
|
|
3
|
+
"version": "2.2.14",
|
|
4
4
|
"description": "Sprintpilot — autopilot and multi-agent addon for BMad Method v6: git workflow, parallel agents, autonomous story execution",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"repository": {
|