@searls/turbocommit 0.16.1 → 0.16.2

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/README.md CHANGED
@@ -80,8 +80,14 @@ Each touched checkout:
80
80
  - Gives the configured title agent repository-specific bounded diff context.
81
81
 
82
82
  Turbocommit attempts every commit before pushing every successful commit whose
83
- repository has `push: true`. Failures are retained and retried on a later Stop,
84
- along with every transcript accumulated before the commit succeeds.
83
+ repository has `push: true`. Staging and committing are retried a few times
84
+ when Git is momentarily unavailable, for example while Xcode holds the index
85
+ lock. Failures are retained and retried on a later Stop in that checkout by
86
+ any session, along with every transcript accumulated before the commit
87
+ succeeds, and the failed session's claims are released so they do not block
88
+ other sessions meanwhile. Each `fail` monitor event records Git's error. A
89
+ deletion the session already staged with `git rm` is committed with the rest
90
+ of its paths.
85
91
 
86
92
  Separate worktrees are committed independently. Enabled submodules are
87
93
  committed and pushed before enabled parent repositories. When one turn creates
package/lib/git.js CHANGED
@@ -115,12 +115,46 @@ function stageAll (cwd) {
115
115
 
116
116
  function stagePaths (cwd, filePaths) {
117
117
  const changed = changedPaths(cwd, filePaths)
118
- const paths = pathspecs(cwd, changed)
119
- if (paths.length === 0) return []
120
- git(`add -A -- ${paths.map(quote).join(' ')}`, { cwd })
118
+ if (changed.length === 0) return []
119
+ // A deletion the session already staged (git rm) matches nothing on disk or
120
+ // in the index, and `git add` aborts on such a pathspec. It is already
121
+ // staged, so only the remaining paths are added.
122
+ const paths = pathspecs(cwd, changed.filter(file => existsOnDisk(file) || isIndexed(cwd, file)))
123
+ if (paths.length > 0) git(`add -A -- ${paths.map(quote).join(' ')}`, { cwd })
121
124
  return changed
122
125
  }
123
126
 
127
+ function existsOnDisk (file) {
128
+ try {
129
+ fs.lstatSync(file)
130
+ return true
131
+ } catch {
132
+ return false
133
+ }
134
+ }
135
+
136
+ function isIndexed (cwd, file) {
137
+ const spec = pathspecs(cwd, [file])
138
+ if (spec.length === 0) return false
139
+ return git(`ls-files --cached -z -- ${quote(spec[0])}`, { cwd }) !== ''
140
+ }
141
+
142
+ /**
143
+ * Git refuses to work while another process holds the index (Xcode and other
144
+ * tools take `.git/index.lock` briefly), so a staging or commit step is tried
145
+ * again before its failure is allowed to strand a turn's work.
146
+ */
147
+ function withGitRetry (operation, attempts = 3, delayMs = 250) {
148
+ for (let attempt = 1; ; attempt++) {
149
+ try {
150
+ return operation()
151
+ } catch (err) {
152
+ if (attempt >= attempts) throw err
153
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, delayMs)
154
+ }
155
+ }
156
+ }
157
+
124
158
  function commitPaths (cwd, filePaths, headline, body, opts = {}) {
125
159
  if (opts.pathScoped && hasRepositoryOperation(cwd)) return null
126
160
  const changed = stagePaths(cwd, filePaths)
@@ -271,6 +305,7 @@ function hasRepositoryOperation (cwd) {
271
305
  }
272
306
 
273
307
  module.exports = {
308
+ withGitRetry,
274
309
  git,
275
310
  canonicalRoot,
276
311
  canonicalTrackedPath,
package/lib/run.js CHANGED
@@ -4,7 +4,7 @@ const path = require('path')
4
4
  const { loadJson } = require('./io')
5
5
  const { parseTranscript, parseCodexTranscript, formatBody, formatTitleTranscript, extractHeadline, extractModel, extractCodexModel } = require('./transcript')
6
6
  const { runTitleAgent, runBodyAgent } = require('./agent')
7
- const { gitRoot, hasChanges, changedPaths, addAndCommit, stagePaths, commitPaths, commitStagedPaths, stagedChangeContext, currentBranch, pushClean, hasRepositoryOperation } = require('./git')
7
+ const { withGitRetry, gitRoot, hasChanges, changedPaths, addAndCommit, stagePaths, commitPaths, commitStagedPaths, stagedChangeContext, currentBranch, pushClean, hasRepositoryOperation } = require('./git')
8
8
  const { logEvent } = require('./log')
9
9
  const { wrapText } = require('./wrap')
10
10
  const {
@@ -281,6 +281,8 @@ function runSingle (input, opts = {}) {
281
281
  const project = path.basename(root)
282
282
  const branch = currentBranch(root)
283
283
  let context = 0
284
+ // Work a failed commit could not land is retained for a later Stop.
285
+ let retained = null
284
286
  try { context = fs.statSync(transcriptPath).size } catch {}
285
287
 
286
288
  const sessionId = hookInput.sessionId
@@ -344,6 +346,7 @@ function runSingle (input, opts = {}) {
344
346
  logEvent('start', { harness: hookInput.harness, project, branch, context })
345
347
 
346
348
  const formattedTranscript = formatBody(effectivePairs)
349
+ if (sessionId) retained = { body: formattedTranscript, paths: ownedPaths }
347
350
 
348
351
  // Title: agent by default, transcript if opted out
349
352
  let headline
@@ -397,9 +400,9 @@ function runSingle (input, opts = {}) {
397
400
  const redactions = buildRedactions()
398
401
  const safeHeadline = redact(headline, redactions)
399
402
  const safeBody = redact(wrappedBody + tag, redactions)
400
- const sha = sessionId
403
+ const sha = withGitRetry(() => sessionId
401
404
  ? commitPaths(root, ownedPaths, safeHeadline, safeBody)
402
- : addAndCommit(root, safeHeadline, safeBody)
405
+ : addAndCommit(root, safeHeadline, safeBody))
403
406
  if (!sha) {
404
407
  if (sessionId) cleanupTracking(root, sessionId)
405
408
  logEvent('skip', {
@@ -432,7 +435,8 @@ function runSingle (input, opts = {}) {
432
435
  }
433
436
  cleanupStale(root)
434
437
  } catch (err) {
435
- logEvent('fail', { harness: hookInput.harness, project, branch, context })
438
+ logEvent('fail', { harness: hookInput.harness, project, branch, context, error: failureMessage(err) })
439
+ if (retained) retainFailedWork(root, sessionId, retained, pairs.length)
436
440
  throw err
437
441
  }
438
442
  }
@@ -534,7 +538,7 @@ function runMulti (hookInput, anchor, currentRepos, manifest, opts = {}) {
534
538
 
535
539
  try {
536
540
  logEvent('start', { harness: hookInput.harness, project, branch, context })
537
- const stagedPaths = stagePaths(root, currentPaths)
541
+ const stagedPaths = withGitRetry(() => stagePaths(root, currentPaths))
538
542
  if (stagedPaths.length === 0) {
539
543
  manifest.repos = manifest.repos.filter(candidate => candidate !== repo)
540
544
  logEvent('skip', { harness: hookInput.harness, project, branch, context, reason: 'no-path-changes' })
@@ -582,7 +586,7 @@ function runMulti (hookInput, anchor, currentRepos, manifest, opts = {}) {
582
586
 
583
587
  const safeHeadline = redact(headline, redactions)
584
588
  const safeBody = redact(body, redactions)
585
- const sha = commitStagedPaths(root, stagedPaths, safeHeadline, safeBody)
589
+ const sha = withGitRetry(() => commitStagedPaths(root, stagedPaths, safeHeadline, safeBody))
586
590
  if (!sha) throw new Error('Tracked paths changed before commit')
587
591
  if (sessionId) saveMultiWatermark(root, sessionId, sha)
588
592
  if (root === anchor && sessionId) {
@@ -597,8 +601,8 @@ function runMulti (hookInput, anchor, currentRepos, manifest, opts = {}) {
597
601
  context,
598
602
  title: safeHeadline
599
603
  })
600
- } catch {
601
- logEvent('fail', { harness: hookInput.harness, project, branch, context })
604
+ } catch (err) {
605
+ logEvent('fail', { harness: hookInput.harness, project, branch, context, error: failureMessage(err) })
602
606
  }
603
607
  }
604
608
 
@@ -631,6 +635,30 @@ function runMulti (hookInput, anchor, currentRepos, manifest, opts = {}) {
631
635
  cleanupStale(anchor)
632
636
  }
633
637
 
638
+ /**
639
+ * A single-repository commit that failed joins the retry manifest, which any
640
+ * later Stop in this checkout replays. The session's tracking file is
641
+ * released so its claims stop blocking other sessions in the meantime.
642
+ */
643
+ function retainFailedWork (root, sessionId, retained, pairCount) {
644
+ try {
645
+ const manifest = loadManifest(root)
646
+ appendWork(manifest, root, {
647
+ key: cryptoKey(`${sessionId}\n${pairCount}\n${retained.body}`),
648
+ body: retained.body,
649
+ sessionIds: [],
650
+ paths: retained.paths
651
+ })
652
+ saveManifest(root, manifest)
653
+ cleanupTracking(root, sessionId)
654
+ } catch {}
655
+ }
656
+
657
+ function failureMessage (err) {
658
+ const text = err?.stderr?.toString?.().trim() || err?.message || String(err)
659
+ return text.split('\n')[0].slice(0, 200)
660
+ }
661
+
634
662
  function transcriptSize (transcriptPath) {
635
663
  try {
636
664
  return fs.statSync(transcriptPath).size
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@searls/turbocommit",
3
- "version": "0.16.1",
3
+ "version": "0.16.2",
4
4
  "description": "Auto-commit after every AI coding agent turn",
5
5
  "bin": {
6
6
  "turbocommit": "./cli.js"