@ikunin/sprintpilot 2.2.13 → 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.
@@ -702,6 +702,10 @@ function composeRuntimeState(persisted, profile, projectRoot) {
702
702
  // not lifetime). Persisted across in-session resumes so a `pause` mid-flow
703
703
  // doesn't reset progress against the limit.
704
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,
705
709
  // halt_requested is intentionally NOT carried forward here: cmdStart
706
710
  // clears it on each new session (a `pause` cleanly halts THIS session
707
711
  // and the next /sprint-autopilot-on resumes normally).
@@ -731,6 +735,7 @@ function persistRuntimeState(runtime, profile, projectRoot) {
731
735
  land_pending: runtime.land_pending,
732
736
  pending_alternative: runtime.pending_alternative || null,
733
737
  session_stories_completed: runtime.session_stories_completed || 0,
738
+ lock_session_id: runtime.lock_session_id || null,
734
739
  };
735
740
  return persistState(updates, profile, projectRoot, runtime.story_key || 'sprint');
736
741
  }
@@ -1066,6 +1071,96 @@ function lockUserBranchIfNeeded(runtime, profile, projectRoot) {
1066
1071
  return null;
1067
1072
  }
1068
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
+
1069
1164
  function cmdStart(opts) {
1070
1165
  const projectRoot = resolveProjectRoot(opts);
1071
1166
  const { typed: profile } = resolveProfile(projectRoot, opts.profile);
@@ -1118,6 +1213,49 @@ function cmdStart(opts) {
1118
1213
  }
1119
1214
  }
1120
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
+
1121
1259
  // Persist the new queue BEFORE composing runtime state so the queue
1122
1260
  // head is visible to composeRuntimeState's resolver.
1123
1261
  if (explicitQueue.length > 0) {
@@ -1419,4 +1557,11 @@ if (require.main === module) {
1419
1557
  process.exit(main(process.argv.slice(2)));
1420
1558
  }
1421
1559
 
1422
- module.exports = { main, SUBCOMMANDS, decorateGitOp, decorateRunScript, composeRuntimeState };
1560
+ module.exports = {
1561
+ main,
1562
+ SUBCOMMANDS,
1563
+ decorateGitOp,
1564
+ decorateRunScript,
1565
+ composeRuntimeState,
1566
+ acquireAutopilotLock,
1567
+ };
@@ -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).
@@ -1,6 +1,6 @@
1
1
  addon:
2
2
  name: sprintpilot
3
- version: 2.2.13
3
+ version: 2.2.14
4
4
  description: Sprintpilot — autopilot and multi-agent addon for BMad Method (git workflow, parallel agents, autonomous story execution)
5
5
  bmad_compatibility: ">=6.2.0"
6
6
  modules:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ikunin/sprintpilot",
3
- "version": "2.2.13",
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": {