@sebastianandreasson/pi-autonomous-agents 0.3.0 → 0.5.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.
@@ -10,6 +10,7 @@ import {
10
10
  getHeartbeatDecision,
11
11
  resolveHeartbeatConfig,
12
12
  } from './pi-heartbeat.mjs'
13
+ import { signalProcessTree } from './pi-repo.mjs'
13
14
 
14
15
  function createJsonlReader(stream, onLine) {
15
16
  const rl = createInterface({ input: stream })
@@ -54,6 +55,44 @@ function extractToolTarget(toolName, args) {
54
55
  return ''
55
56
  }
56
57
 
58
+ function extractShellCommand(args) {
59
+ if (!args || typeof args !== 'object') {
60
+ return ''
61
+ }
62
+
63
+ if (typeof args.command === 'string') {
64
+ return args.command
65
+ }
66
+
67
+ if (typeof args.cmd === 'string') {
68
+ return args.cmd
69
+ }
70
+
71
+ return ''
72
+ }
73
+
74
+ function isLargeShellRead(command) {
75
+ const text = String(command ?? '').trim()
76
+ if (text === '') {
77
+ return false
78
+ }
79
+
80
+ if (/^\s*cat\s+\S+/.test(text)) {
81
+ return true
82
+ }
83
+
84
+ const sedMatch = text.match(/sed\s+-n\s+['"]?(\d+)\s*,\s*(\d+)p['"]?/)
85
+ if (sedMatch) {
86
+ const start = Number.parseInt(sedMatch[1], 10)
87
+ const end = Number.parseInt(sedMatch[2], 10)
88
+ if (Number.isFinite(start) && Number.isFinite(end) && end >= start) {
89
+ return (end - start) >= 120
90
+ }
91
+ }
92
+
93
+ return false
94
+ }
95
+
57
96
  function extractAssistantText(message) {
58
97
  if (!message || message.role !== 'assistant' || !Array.isArray(message.content)) {
59
98
  return ''
@@ -113,6 +152,7 @@ async function run() {
113
152
  const child = spawn(cli, args, {
114
153
  cwd: request.cwd,
115
154
  env: process.env,
155
+ detached: process.platform !== 'win32',
116
156
  stdio: ['pipe', 'pipe', 'pipe'],
117
157
  })
118
158
 
@@ -201,10 +241,10 @@ async function run() {
201
241
  closeAssistantLine()
202
242
  writeLive(`[PI guard] ${formatHeartbeatTimeoutMessage(decision)} Aborting current turn (pid=${child.pid ?? 'unknown'}).\n`)
203
243
  void send({ type: 'abort' }).catch(() => {})
204
- child.kill('SIGTERM')
244
+ signalProcessTree(child.pid, 'SIGTERM')
205
245
  setTimeout(() => {
206
246
  if (child.exitCode === null) {
207
- child.kill('SIGKILL')
247
+ signalProcessTree(child.pid, 'SIGKILL')
208
248
  }
209
249
  }, 1000)
210
250
  }
@@ -295,6 +335,7 @@ async function run() {
295
335
  activeToolName = String(data.toolName ?? '')
296
336
  activeToolStartedAt = Date.now()
297
337
  const target = extractToolTarget(data.toolName, data.args)
338
+ const shellCommand = data.toolName === 'bash' ? extractShellCommand(data.args) : ''
298
339
  if (signature === lastToolSignature) {
299
340
  repeatedToolCount += 1
300
341
  } else {
@@ -325,6 +366,9 @@ async function run() {
325
366
  }
326
367
 
327
368
  writeLive(`[PI tool:start] ${data.toolName}${suffix}\n`)
369
+ if (data.toolName === 'bash' && isLargeShellRead(shellCommand)) {
370
+ writeLive('[PI warning] large bash file read detected; prefer read or a smaller exact window to avoid truncated context.\n')
371
+ }
328
372
  }
329
373
 
330
374
  if (data.type === 'tool_execution_end') {
@@ -536,10 +580,10 @@ async function run() {
536
580
  }
537
581
  pending.clear()
538
582
 
539
- child.kill('SIGTERM')
583
+ signalProcessTree(child.pid, 'SIGTERM')
540
584
  await new Promise((resolve) => {
541
585
  const timeout = setTimeout(() => {
542
- child.kill('SIGKILL')
586
+ signalProcessTree(child.pid, 'SIGKILL')
543
587
  resolve()
544
588
  }, 1000)
545
589
 
@@ -12,8 +12,11 @@ import {
12
12
  } from './pi-prompts.mjs'
13
13
  import { appendTelemetry, ensureTelemetryFiles } from './pi-telemetry.mjs'
14
14
  import {
15
+ acquireRunLock,
15
16
  appendLog,
17
+ collectLargeFileWarnings,
16
18
  commitStagedFiles,
19
+ createRunId,
17
20
  didRepoChange,
18
21
  ensureFileExists,
19
22
  ensureRepo,
@@ -24,10 +27,12 @@ import {
24
27
  readOptionalTextFile,
25
28
  readSessionId,
26
29
  readState,
30
+ releaseRunLock,
27
31
  runVerification,
28
32
  runShellCommand,
29
33
  stageFiles,
30
34
  unstageFiles,
35
+ updateRunLock,
31
36
  runVisualCapture,
32
37
  timestamp,
33
38
  writeChangedFiles,
@@ -65,7 +70,7 @@ function printTerminalSummary(config, summary) {
65
70
  }
66
71
 
67
72
  const lines = [
68
- `[PI supervisor] iteration=${summary.iteration} phase="${summary.phase}"`,
73
+ `[PI supervisor] run_id=${summary.runId || config.runId || ''} iteration=${summary.iteration} phase="${summary.phase}"`,
69
74
  `[PI supervisor] task=${summary.taskFile || toDisplayPath(config, config.taskFile)} developer_instructions=${summary.developerInstructionsFile || toDisplayPath(config, config.developerInstructionsFile)} tester_instructions=${summary.testerInstructionsFile || toDisplayPath(config, config.testerInstructionsFile)}`,
70
75
  `[PI supervisor] transport=${config.transport} developer_model=${summary.developerModel || resolveRoleModelName(config, 'developer') || '(PI default)'} tester_model=${summary.testerModel || resolveRoleModelName(config, 'tester') || '(PI default)'}`,
71
76
  `[PI supervisor] developer=${summary.developerStatus} tester=${summary.testerStatus} verification=${summary.verificationStatus}`,
@@ -79,6 +84,10 @@ function printTerminalSummary(config, summary) {
79
84
  lines.push(`[PI supervisor] notes=${summary.notes}`)
80
85
  }
81
86
 
87
+ if (Array.isArray(summary.largeFileWarnings) && summary.largeFileWarnings.length > 0) {
88
+ lines.push(`[PI supervisor] large_file_warnings=${formatLargeFileWarningsInline(summary.largeFileWarnings)}`)
89
+ }
90
+
82
91
  if (summary.terminalReason) {
83
92
  lines.push(`[PI supervisor] terminal_reason=${summary.terminalReason}`)
84
93
  }
@@ -147,9 +156,13 @@ function formatIterationSummary(summary) {
147
156
 
148
157
  async function writeIterationSummary(config, summary) {
149
158
  await writeTextFile(config.lastIterationSummaryFile, formatIterationSummary(summary))
159
+ if (config.runLastIterationSummaryFile && config.runLastIterationSummaryFile !== config.lastIterationSummaryFile) {
160
+ await writeTextFile(config.runLastIterationSummaryFile, formatIterationSummary(summary))
161
+ }
150
162
  }
151
163
 
152
164
  function createIterationSummary({
165
+ runId,
153
166
  iteration,
154
167
  phase,
155
168
  task,
@@ -162,12 +175,14 @@ function createIterationSummary({
162
175
  gitFinalizeStatus,
163
176
  visualStatus,
164
177
  terminalReason,
178
+ largeFileWarnings,
165
179
  sessionId,
166
180
  developerModel,
167
181
  testerModel,
168
182
  visualModel,
169
183
  }) {
170
184
  return {
185
+ runId,
171
186
  iteration,
172
187
  phase,
173
188
  task,
@@ -180,6 +195,7 @@ function createIterationSummary({
180
195
  gitFinalizeStatus,
181
196
  visualStatus,
182
197
  terminalReason,
198
+ largeFileWarnings,
183
199
  sessionId,
184
200
  developerModel,
185
201
  testerModel,
@@ -187,10 +203,63 @@ function createIterationSummary({
187
203
  }
188
204
  }
189
205
 
206
+ async function persistStateSnapshot(config, state) {
207
+ await writeState(config.stateFile, state)
208
+ if (config.runStateFile && config.runStateFile !== config.stateFile) {
209
+ await writeState(config.runStateFile, state)
210
+ }
211
+ }
212
+
213
+ async function updateRunOwnership(config, fields = {}) {
214
+ if (!config.activeRunFile || !config.runId) {
215
+ return
216
+ }
217
+
218
+ await updateRunLock(config.activeRunFile, {
219
+ runId: config.runId,
220
+ pid: process.pid,
221
+ heartbeatAt: timestamp(),
222
+ ...fields,
223
+ })
224
+ }
225
+
190
226
  function didInvocationCreateCommit(invocation) {
191
227
  return invocation?.beforeSnapshot?.head !== invocation?.afterSnapshot?.head
192
228
  }
193
229
 
230
+ function mergeLargeFileWarnings(existing, incoming) {
231
+ const merged = new Map()
232
+ for (const warning of [...(existing || []), ...(incoming || [])]) {
233
+ if (!warning?.file) {
234
+ continue
235
+ }
236
+ const key = `${warning.kind}:${warning.file}`
237
+ const current = merged.get(key)
238
+ if (!current || Number(warning.lineCount) > Number(current.lineCount)) {
239
+ merged.set(key, warning)
240
+ }
241
+ }
242
+ return [...merged.values()].sort((left, right) => right.lineCount - left.lineCount)
243
+ }
244
+
245
+ function findLargeFileWarnings(config, files) {
246
+ return collectLargeFileWarnings(config.cwd, files, {
247
+ largeFileWarningLines: config.largeFileWarningLines,
248
+ largeSpecWarningLines: config.largeSpecWarningLines,
249
+ })
250
+ }
251
+
252
+ function formatLargeFileWarningsInline(warnings) {
253
+ const list = Array.isArray(warnings) ? warnings : []
254
+ if (list.length === 0) {
255
+ return ''
256
+ }
257
+ return list
258
+ .slice(0, 3)
259
+ .map((warning) => `${warning.file}(${warning.lineCount}${warning.kind === 'large_spec' ? ',spec' : ''})`)
260
+ .join(', ')
261
+ }
262
+
194
263
  function clampPromptLines(text, maxLines) {
195
264
  const normalized = String(text ?? '').trim()
196
265
  if (normalized === '') {
@@ -232,6 +301,7 @@ function isInfrastructureVerificationFailure(output) {
232
301
  async function recordEvent(config, event) {
233
302
  await appendTelemetry(config, {
234
303
  timestamp: timestamp(),
304
+ runId: config.runId || '',
235
305
  ...event,
236
306
  })
237
307
  }
@@ -644,6 +714,7 @@ async function runMainTurnWithRetries({ config, iteration, phase, sessionId, ses
644
714
  prompt = buildSteeringPrompt(config, reason, {
645
715
  visualFeedback: await readLatestVisualFeedback(config),
646
716
  testerFeedback: await readLatestTesterFeedback(config),
717
+ largeFileWarnings: findLargeFileWarnings(config, listChangedFiles(config.cwd)),
647
718
  })
648
719
 
649
720
  if (shouldRetryForTimeout || shouldRetryForNoChange) {
@@ -656,12 +727,14 @@ async function runMainTurnWithRetries({ config, iteration, phase, sessionId, ses
656
727
  }
657
728
 
658
729
  async function runFixTurn({ config, iteration, phase, sessionId, sessionFile, testerOutput }) {
730
+ const largeFileWarnings = findLargeFileWarnings(config, listChangedFiles(config.cwd))
659
731
  const fixPrompt = buildFixPrompt(
660
732
  config,
661
733
  clampPromptLines(testerOutput, Number(config.maxVerificationExcerptLines) || 40),
662
734
  {
663
735
  visualFeedback: await readLatestVisualFeedback(config),
664
736
  testerFeedback: await readLatestTesterFeedback(config),
737
+ largeFileWarnings,
665
738
  }
666
739
  )
667
740
  return await runAgentInvocation({
@@ -762,6 +835,7 @@ async function runTesterTurn({
762
835
  developerNotes,
763
836
  reason,
764
837
  }) {
838
+ const largeFileWarnings = findLargeFileWarnings(config, changedFiles)
765
839
  const prompt = buildTesterPrompt(config, {
766
840
  phase,
767
841
  task,
@@ -770,6 +844,7 @@ async function runTesterTurn({
770
844
  reason,
771
845
  visualFeedback: await readLatestVisualFeedback(config),
772
846
  testerFeedback: await readLatestTesterFeedback(config),
847
+ largeFileWarnings,
773
848
  })
774
849
 
775
850
  const invocation = await runAgentInvocation({
@@ -835,6 +910,7 @@ async function runTesterCommitTurn({
835
910
  developerNotes,
836
911
  reason,
837
912
  }) {
913
+ const largeFileWarnings = findLargeFileWarnings(config, changedFiles)
838
914
  const prompt = buildCommitPrompt(config, {
839
915
  phase,
840
916
  task,
@@ -843,6 +919,7 @@ async function runTesterCommitTurn({
843
919
  reason,
844
920
  visualFeedback: await readLatestVisualFeedback(config),
845
921
  testerFeedback: await readLatestTesterFeedback(config),
922
+ largeFileWarnings,
846
923
  })
847
924
 
848
925
  const invocation = await runAgentInvocation({
@@ -1029,6 +1106,13 @@ async function runIteration({ config, state, iteration }) {
1029
1106
  const iterationStartSnapshot = getRepoSnapshot(config.cwd)
1030
1107
  const taskInfo = findFirstUncheckedTaskInfo(config.taskFile)
1031
1108
  if (!taskInfo.hasUncheckedTasks) {
1109
+ await updateRunOwnership(config, {
1110
+ status: 'idle',
1111
+ iteration,
1112
+ phase: taskInfo.phase || 'complete',
1113
+ task: '',
1114
+ lastCompletedIteration: iteration,
1115
+ })
1032
1116
  await appendLog(config.logFile, 'No unchecked tasks remain in TODOS.md')
1033
1117
  return {
1034
1118
  stateUpdate: {
@@ -1039,9 +1123,12 @@ async function runIteration({ config, state, iteration }) {
1039
1123
  lastPhase: taskInfo.phase,
1040
1124
  lastStatus: 'complete',
1041
1125
  lastVerificationStatus: 'not_needed',
1126
+ runId: config.runId || '',
1127
+ inProgress: null,
1042
1128
  lastRunAt: timestamp(),
1043
1129
  },
1044
1130
  summary: {
1131
+ runId: config.runId || '',
1045
1132
  iteration,
1046
1133
  phase: taskInfo.phase || 'complete',
1047
1134
  task: '',
@@ -1054,6 +1141,7 @@ async function runIteration({ config, state, iteration }) {
1054
1141
  gitFinalizeStatus: 'not_run',
1055
1142
  visualStatus: 'not_run',
1056
1143
  terminalReason: 'all_tasks_complete',
1144
+ largeFileWarnings: [],
1057
1145
  notes: 'No unchecked tasks remain in TODOS.md.',
1058
1146
  sessionId: state.sessionId || '',
1059
1147
  outputPath: config.lastAgentOutputFile,
@@ -1070,6 +1158,26 @@ async function runIteration({ config, state, iteration }) {
1070
1158
 
1071
1159
  const phase = taskInfo.phase || 'unknown'
1072
1160
  const task = taskInfo.task || 'unknown'
1161
+ const inProgressState = {
1162
+ ...state,
1163
+ runId: config.runId || '',
1164
+ inProgress: {
1165
+ runId: config.runId || '',
1166
+ status: 'in_progress',
1167
+ iteration,
1168
+ phase,
1169
+ task,
1170
+ startedAt: timestamp(),
1171
+ transport: config.transport,
1172
+ },
1173
+ }
1174
+ await persistStateSnapshot(config, inProgressState)
1175
+ await updateRunOwnership(config, {
1176
+ status: 'iteration_in_progress',
1177
+ iteration,
1178
+ phase,
1179
+ task,
1180
+ })
1073
1181
  const canResumePriorSession = (
1074
1182
  state.lastTransport === config.transport
1075
1183
  && state.lastPiModel === developerModelName
@@ -1103,6 +1211,7 @@ async function runIteration({ config, state, iteration }) {
1103
1211
  let commitPlanFound = false
1104
1212
  let gitFinalizeStatus = 'not_run'
1105
1213
  let terminalReason = mainInvocation.result.terminalReason || ''
1214
+ let largeFileWarnings = findLargeFileWarnings(config, mainInvocation.changedFiles)
1106
1215
  const noteParts = [`developer: ${mainInvocation.result.notes}`]
1107
1216
 
1108
1217
  if (mainInvocation.result.status === 'success' && config.transport === 'mock') {
@@ -1157,6 +1266,7 @@ async function runIteration({ config, state, iteration }) {
1157
1266
  testerVerdict = testerInvocation.testerVerdict
1158
1267
  commitPlanFound = testerInvocation.commitPlanFound === true
1159
1268
  terminalReason = testerInvocation.result.terminalReason || terminalReason
1269
+ largeFileWarnings = mergeLargeFileWarnings(largeFileWarnings, findLargeFileWarnings(config, listChangedFiles(config.cwd)))
1160
1270
  noteParts.push(`tester: ${testerInvocation.result.notes}`)
1161
1271
  await writeTesterFeedback(config, {
1162
1272
  iteration,
@@ -1184,6 +1294,7 @@ async function runIteration({ config, state, iteration }) {
1184
1294
  testerVerdict = testerCommitInvocation.testerVerdict
1185
1295
  commitPlanFound = testerCommitInvocation.commitPlanFound === true
1186
1296
  terminalReason = testerCommitInvocation.result.terminalReason || terminalReason
1297
+ largeFileWarnings = mergeLargeFileWarnings(largeFileWarnings, findLargeFileWarnings(config, listChangedFiles(config.cwd)))
1187
1298
  noteParts.push(`tester_commit: ${testerCommitInvocation.result.notes}`)
1188
1299
  await writeTesterFeedback(config, {
1189
1300
  iteration,
@@ -1241,6 +1352,7 @@ async function runIteration({ config, state, iteration }) {
1241
1352
  sessionFile = fixInvocation.result.sessionFile || sessionFile
1242
1353
  developerStatus = fixInvocation.result.status
1243
1354
  terminalReason = fixInvocation.result.terminalReason || 'developer_fix_incomplete'
1355
+ largeFileWarnings = mergeLargeFileWarnings(largeFileWarnings, findLargeFileWarnings(config, listChangedFiles(config.cwd)))
1244
1356
  noteParts.push(`developer_fix: ${fixInvocation.result.notes}`)
1245
1357
 
1246
1358
  if (fixInvocation.result.status === 'success') {
@@ -1258,6 +1370,7 @@ async function runIteration({ config, state, iteration }) {
1258
1370
  testerVerdict = testerRecheck.testerVerdict
1259
1371
  commitPlanFound = testerRecheck.commitPlanFound === true
1260
1372
  terminalReason = testerRecheck.result.terminalReason || terminalReason
1373
+ largeFileWarnings = mergeLargeFileWarnings(largeFileWarnings, findLargeFileWarnings(config, listChangedFiles(config.cwd)))
1261
1374
  noteParts.push(`tester_recheck: ${testerRecheck.result.notes}`)
1262
1375
  await writeTesterFeedback(config, {
1263
1376
  iteration,
@@ -1285,6 +1398,7 @@ async function runIteration({ config, state, iteration }) {
1285
1398
  testerVerdict = testerCommitInvocation.testerVerdict
1286
1399
  commitPlanFound = testerCommitInvocation.commitPlanFound === true
1287
1400
  terminalReason = testerCommitInvocation.result.terminalReason || terminalReason
1401
+ largeFileWarnings = mergeLargeFileWarnings(largeFileWarnings, findLargeFileWarnings(config, listChangedFiles(config.cwd)))
1288
1402
  noteParts.push(`tester_commit: ${testerCommitInvocation.result.notes}`)
1289
1403
  await writeTesterFeedback(config, {
1290
1404
  iteration,
@@ -1432,15 +1546,27 @@ async function runIteration({ config, state, iteration }) {
1432
1546
  lastRunAt: timestamp(),
1433
1547
  successfulIterations,
1434
1548
  lastVisualStatus: visualStatus,
1549
+ runId: config.runId || '',
1550
+ inProgress: null,
1435
1551
  }
1436
1552
 
1553
+ await updateRunOwnership(config, {
1554
+ status: 'idle',
1555
+ iteration,
1556
+ phase,
1557
+ task,
1558
+ lastCompletedIteration: iteration,
1559
+ lastStatus: finalStatus,
1560
+ })
1561
+
1437
1562
  await appendLog(
1438
1563
  config.logFile,
1439
- `Finished iteration ${iteration} with status=${finalStatus} verification=${finalVerificationStatus} tester_verdict=${testerVerdict} commit_plan_found=${commitPlanFound} terminal_reason=${terminalReason}`
1564
+ `Finished iteration ${iteration} with status=${finalStatus} verification=${finalVerificationStatus} tester_verdict=${testerVerdict} commit_plan_found=${commitPlanFound} terminal_reason=${terminalReason}${largeFileWarnings.length > 0 ? ` large_file_warnings=${formatLargeFileWarningsInline(largeFileWarnings)}` : ''}`
1440
1565
  )
1441
1566
 
1442
1567
  const iterationEndSnapshot = getRepoSnapshot(config.cwd)
1443
1568
  const iterationSummary = createIterationSummary({
1569
+ runId: config.runId || '',
1444
1570
  iteration,
1445
1571
  phase,
1446
1572
  task,
@@ -1453,6 +1579,7 @@ async function runIteration({ config, state, iteration }) {
1453
1579
  gitFinalizeStatus,
1454
1580
  visualStatus,
1455
1581
  terminalReason,
1582
+ largeFileWarnings,
1456
1583
  sessionId,
1457
1584
  developerModel: developerModelName,
1458
1585
  testerModel: testerModelName,
@@ -1486,12 +1613,14 @@ async function runIteration({ config, state, iteration }) {
1486
1613
  testerVerdict,
1487
1614
  commitPlanFound,
1488
1615
  terminalReason,
1616
+ riskWarnings: formatLargeFileWarningsInline(largeFileWarnings),
1489
1617
  notes: noteParts.join(' | '),
1490
1618
  })
1491
1619
 
1492
1620
  return {
1493
1621
  stateUpdate: nextState,
1494
1622
  summary: {
1623
+ runId: config.runId || '',
1495
1624
  iteration,
1496
1625
  phase,
1497
1626
  task,
@@ -1504,6 +1633,7 @@ async function runIteration({ config, state, iteration }) {
1504
1633
  gitFinalizeStatus,
1505
1634
  visualStatus,
1506
1635
  terminalReason,
1636
+ largeFileWarnings,
1507
1637
  notes: noteParts.join(' | '),
1508
1638
  sessionId,
1509
1639
  outputPath: config.lastAgentOutputFile,
@@ -1521,40 +1651,95 @@ async function runIteration({ config, state, iteration }) {
1521
1651
 
1522
1652
  async function main() {
1523
1653
  const config = loadConfig(process.argv[2] ?? 'once')
1654
+ const runId = createRunId()
1655
+ const runStartedAt = timestamp()
1656
+ const runDir = path.join(config.piRuntimeDir, 'runs', runId)
1657
+ config.runId = runId
1658
+ config.runStartedAt = runStartedAt
1659
+ config.runRuntimeDir = runDir
1660
+ config.runLogFile = path.join(runDir, 'pi.log')
1661
+ config.runTelemetryJsonl = path.join(runDir, 'pi_telemetry.jsonl')
1662
+ config.runTelemetryCsv = path.join(runDir, 'pi_telemetry.csv')
1663
+ config.runStateFile = path.join(runDir, 'state.json')
1664
+ config.runLastIterationSummaryFile = path.join(runDir, 'last-iteration.json')
1665
+
1524
1666
  ensureRepo(config.cwd)
1525
1667
  await ensureFileExists(config.taskFile, 'task file')
1526
1668
  await ensureFileExists(config.developerInstructionsFile, 'developer instructions file')
1527
1669
  await ensureFileExists(config.testerInstructionsFile, 'tester instructions file')
1528
- await ensureTelemetryFiles(config)
1529
- await runStartupPreflight(config)
1530
-
1531
- let state = await readState(config.stateFile)
1532
- let completedIterations = 0
1533
-
1534
- while (!stopRequested) {
1535
- const iteration = state.iteration + 1
1536
- const result = await runIteration({ config, state, iteration })
1537
- await writeIterationSummary(config, result.iterationSummary ?? result.summary)
1538
- state = result.stateUpdate
1539
- await writeState(config.stateFile, state)
1540
- printTerminalSummary(config, result.summary)
1541
- completedIterations += 1
1542
-
1543
- if (result.shouldStop || config.mode !== 'run' || completedIterations >= config.maxIterations) {
1544
- break
1670
+ const lockResult = await acquireRunLock(config.activeRunFile, {
1671
+ runId,
1672
+ pid: process.pid,
1673
+ startedAt: runStartedAt,
1674
+ heartbeatAt: runStartedAt,
1675
+ status: 'starting',
1676
+ iteration: 0,
1677
+ phase: '',
1678
+ task: '',
1679
+ mode: config.mode,
1680
+ configFile: config.configFile,
1681
+ cwd: config.cwd,
1682
+ })
1683
+ try {
1684
+ process.env.PI_RUN_ID = runId
1685
+ process.env.PI_RUN_LOG_FILE = config.runLogFile
1686
+ await ensureTelemetryFiles(config)
1687
+ await appendLog(config.logFile, `Run started pid=${process.pid} mode=${config.mode}`)
1688
+ if (lockResult.staleLock) {
1689
+ await appendLog(
1690
+ config.logFile,
1691
+ `Recovered stale run lock from runId=${String(lockResult.staleLock.runId ?? '')} pid=${String(lockResult.staleLock.pid ?? '')} startedAt=${String(lockResult.staleLock.startedAt ?? '')}`
1692
+ )
1545
1693
  }
1694
+ await runStartupPreflight(config)
1546
1695
 
1547
- await sleep(config.sleepBetweenSeconds)
1548
- }
1696
+ let state = await readState(config.stateFile)
1697
+ if (state?.inProgress?.status === 'in_progress') {
1698
+ await appendLog(
1699
+ config.logFile,
1700
+ `Recovering unfinished iteration=${state.inProgress.iteration} phase="${state.inProgress.phase || ''}" task="${state.inProgress.task || ''}" from runId=${String(state.inProgress.runId || state.runId || '')}`
1701
+ )
1702
+ }
1703
+ let completedIterations = 0
1704
+
1705
+ while (!stopRequested) {
1706
+ const iteration = state?.inProgress?.status === 'in_progress'
1707
+ ? Number(state.inProgress.iteration) || (state.iteration + 1)
1708
+ : state.iteration + 1
1709
+ await updateRunOwnership(config, {
1710
+ status: 'starting_iteration',
1711
+ iteration,
1712
+ })
1713
+ const result = await runIteration({ config, state, iteration })
1714
+ await writeIterationSummary(config, result.iterationSummary ?? result.summary)
1715
+ state = result.stateUpdate
1716
+ await persistStateSnapshot(config, state)
1717
+ printTerminalSummary(config, result.summary)
1718
+ completedIterations += 1
1719
+
1720
+ if (result.shouldStop || config.mode !== 'run' || completedIterations >= config.maxIterations) {
1721
+ break
1722
+ }
1549
1723
 
1550
- if (stopRequested) {
1551
- await appendLog(config.logFile, 'Stop requested by signal')
1724
+ await sleep(config.sleepBetweenSeconds)
1725
+ }
1726
+
1727
+ if (stopRequested) {
1728
+ await appendLog(config.logFile, 'Stop requested by signal')
1729
+ }
1730
+ } finally {
1731
+ await updateRunOwnership(config, {
1732
+ status: stopRequested ? 'stopped' : 'finished',
1733
+ heartbeatAt: timestamp(),
1734
+ })
1735
+ await releaseRunLock(config.activeRunFile, runId)
1736
+ delete process.env.PI_RUN_ID
1737
+ delete process.env.PI_RUN_LOG_FILE
1552
1738
  }
1553
1739
  }
1554
1740
 
1555
1741
  main().catch(async (error) => {
1556
1742
  const config = loadConfig(process.argv[2] ?? 'once')
1557
- await ensureTelemetryFiles(config)
1558
1743
  await appendLog(config.logFile, `Supervisor error: ${error instanceof Error ? error.stack ?? error.message : String(error)}`)
1559
1744
  console.error(error instanceof Error ? error.message : String(error))
1560
1745
  process.exitCode = 1
@@ -1,6 +1,7 @@
1
1
  import fs from 'node:fs/promises'
2
+ import path from 'node:path'
2
3
 
3
- const CSV_HEADER = 'timestamp,iteration,phase,kind,status,transport,session_id,timed_out,exit_code,duration_seconds,commit_before,commit_after,repo_changed,changed_files_count,verification_status,retry_count,role,model,tool_calls,tool_errors,message_updates,stop_reason,loop_detected,loop_signature,tester_verdict,commit_plan_found,terminal_reason,notes\n'
4
+ const CSV_HEADER = 'timestamp,run_id,iteration,phase,kind,status,transport,session_id,timed_out,exit_code,duration_seconds,commit_before,commit_after,repo_changed,changed_files_count,verification_status,retry_count,role,model,tool_calls,tool_errors,message_updates,stop_reason,loop_detected,loop_signature,tester_verdict,commit_plan_found,terminal_reason,risk_warnings,notes\n'
4
5
 
5
6
  function csvEscape(value) {
6
7
  const text = String(value ?? '')
@@ -14,22 +15,42 @@ export async function ensureTelemetryFiles(config) {
14
15
  await fs.writeFile(config.lastPromptFile, '', 'utf8')
15
16
  await fs.writeFile(config.lastIterationSummaryFile, '', 'utf8')
16
17
 
18
+ await fs.mkdir(path.dirname(config.logFile), { recursive: true })
19
+ await fs.mkdir(path.dirname(config.telemetryJsonl), { recursive: true })
20
+ await fs.mkdir(path.dirname(config.telemetryCsv), { recursive: true })
17
21
  await fs.appendFile(config.logFile, '', 'utf8')
18
22
  await fs.appendFile(config.telemetryJsonl, '', 'utf8')
23
+ if (config.runTelemetryJsonl && config.runTelemetryJsonl !== config.telemetryJsonl) {
24
+ await fs.mkdir(path.dirname(config.runTelemetryJsonl), { recursive: true })
25
+ await fs.appendFile(config.runTelemetryJsonl, '', 'utf8')
26
+ }
19
27
 
20
28
  try {
21
29
  await fs.access(config.telemetryCsv)
22
30
  } catch {
23
31
  await fs.writeFile(config.telemetryCsv, CSV_HEADER, 'utf8')
24
32
  }
33
+
34
+ if (config.runTelemetryCsv && config.runTelemetryCsv !== config.telemetryCsv) {
35
+ try {
36
+ await fs.access(config.runTelemetryCsv)
37
+ } catch {
38
+ await fs.mkdir(path.dirname(config.runTelemetryCsv), { recursive: true })
39
+ await fs.writeFile(config.runTelemetryCsv, CSV_HEADER, 'utf8')
40
+ }
41
+ }
25
42
  }
26
43
 
27
44
  export async function appendTelemetry(config, event) {
28
45
  const jsonLine = `${JSON.stringify(event)}\n`
29
46
  await fs.appendFile(config.telemetryJsonl, jsonLine, 'utf8')
47
+ if (config.runTelemetryJsonl && config.runTelemetryJsonl !== config.telemetryJsonl) {
48
+ await fs.appendFile(config.runTelemetryJsonl, jsonLine, 'utf8')
49
+ }
30
50
 
31
51
  const csvRow = [
32
52
  event.timestamp,
53
+ event.runId,
33
54
  event.iteration,
34
55
  event.phase,
35
56
  event.kind,
@@ -56,10 +77,14 @@ export async function appendTelemetry(config, event) {
56
77
  event.testerVerdict,
57
78
  event.commitPlanFound,
58
79
  event.terminalReason,
80
+ event.riskWarnings,
59
81
  event.notes,
60
82
  ].map(csvEscape).join(',')
61
83
 
62
84
  await fs.appendFile(config.telemetryCsv, `${csvRow}\n`, 'utf8')
85
+ if (config.runTelemetryCsv && config.runTelemetryCsv !== config.telemetryCsv) {
86
+ await fs.appendFile(config.runTelemetryCsv, `${csvRow}\n`, 'utf8')
87
+ }
63
88
  }
64
89
 
65
90
  export async function readTelemetry(config) {
@@ -20,6 +20,9 @@ Rules:
20
20
  - Use the configured smoke verification path as the fast inner-loop gate. Do not replace it with a long full-flow Playwright spec unless the task explicitly requires it.
21
21
  - If a long Playwright happy-path spec changes, validate with smoke plus one narrow targeted spec or deterministic state hook, not the entire full-flow run.
22
22
  - Reserve long full-flow Playwright specs for an explicit nightly or post-run lane, not the developer turn.
23
+ - Use `read` for source inspection. Use shell only for `git`, tests, and narrow diagnostics.
24
+ - If a snippet seems incomplete, reread a smaller exact window instead of another huge overlapping shell range.
25
+ - Do not build edits from large `sed`/`grep` output or from memory after partial shell reads.
23
26
  - Trust tool output over your own guesses.
24
27
  - Do not repeatedly reread or rewrite the same file when one focused fix will do.
25
28
  - After one failed edit attempt, reread the file before retrying.