openclaw-scheduler 0.2.5 → 0.2.6

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.
@@ -34,6 +34,13 @@ const TEST_FRAGMENT_RE = /\b(?:test|tests|spec|coverage|lint|typecheck|tsc|eslin
34
34
  const TEST_APPLICABILITY_RE = /\b(?:test|tests|pytest|jest|vitest|mocha|cypress|playwright|npm\s+test|pnpm\s+test|yarn\s+test|cargo\s+test|go\s+test|rspec)\b/i;
35
35
  const TEST_NEGATION_RE = /\b(?:do\s+not|don't|dont|never|skip|without|no)\s+(?:run\s+)?(?:the\s+)?tests?\b/i;
36
36
  const PUSH_FORBIDDEN_RE = /\b(?:do\s+not|don't|dont|never|must\s+not|should\s+not)\s+(?:git\s+push|push)\b|\bno\s+push\b|\bwithout\s+pushing\b/i;
37
+ const EXPLICIT_TECHNICAL_MARKER_RE = /\b(?:Technically|Technical details)\s*:\s*/i;
38
+ const HUMAN_SUMMARY_SECTION_RE = /(?:^|\n)\s*(?:Human-readable summary|Human summary)\s*:\s*/i;
39
+ const TECHNICAL_DETAILS_SECTION_RE = /(?:^|\n)\s*(?:Technical details?|Details(?:_technical)?)\s*:\s*/i;
40
+ const HUMAN_SUMMARY_LABEL_RE = /^(?:human-readable summary|human summary)\s*:\s*/i;
41
+ const TECHNICAL_DETAILS_LABEL_RE = /^(?:technical details?|details(?:_technical)?)\s*:\s*/i;
42
+ const FINAL_REPORT_HEADING_RE = /^(?:#{1,6}\s*)?(?:root cause|files? changed|changes|validation|tests?(?: run| passed)?|sacrificial(?: delivery)?(?: result)?|deployment(?:\/live-runtime)?(?: step)?|live-runtime(?: step)?|result|results|summary|highlights?|notes?|follow[- ]ups?|next steps?|blockers?|implementation|what changed|verification)\s*:?$/i;
43
+ const FINAL_REPORT_CUE_RE = /\b(?:root cause|files? changed|tests? run|validation|sacrificial(?: delivery)?(?: result)?|deployment(?:\/live-runtime)?(?: step)?|live-runtime(?: step)?|final report|human-readable report|files changed|tests passed)\b/i;
37
44
 
38
45
  export function normalizeCompletionText(value) {
39
46
  if (typeof value !== 'string') return null;
@@ -71,6 +78,56 @@ function cleanMarkdown(text) {
71
78
  .replace(/^>\s?/gm, '');
72
79
  }
73
80
 
81
+ function normalizeReportLineEndings(text) {
82
+ const normalized = normalizeCompletionText(text);
83
+ if (!normalized) return null;
84
+ return stripAnsi(normalized)
85
+ .replace(/\r\n?/g, '\n')
86
+ .split('\n')
87
+ .map(line => line.replace(/[ \t]+$/g, ''))
88
+ .join('\n')
89
+ .replace(/\n{3,}/g, '\n\n')
90
+ .trim();
91
+ }
92
+
93
+ function isLikelyHumanFinalReport(text) {
94
+ const normalized = normalizeReportLineEndings(text);
95
+ if (!normalized) return false;
96
+ if (isGenericOrTrivial(normalized)) return false;
97
+ if (isInternalTransportNoiseText(normalized)) return false;
98
+ if (looksLikeRawPayloadText(normalized)) return false;
99
+ if (looksLikeGunbrokerReport(normalized)) return false;
100
+
101
+ const rawLines = normalized
102
+ .split('\n')
103
+ .map(line => line.trim())
104
+ .filter(Boolean)
105
+ .filter(line => !/^```/.test(line));
106
+ if (rawLines.length < 3) return false;
107
+
108
+ const cleanedLines = rawLines.map(line => cleanMarkdown(line).replace(/\s+/g, ' ').trim());
109
+ const headingCount = cleanedLines.filter(line => FINAL_REPORT_HEADING_RE.test(line)).length;
110
+ const itemCount = rawLines.filter(isItemLine).length;
111
+ const hasCue = FINAL_REPORT_CUE_RE.test(normalized);
112
+ const hasSectionLabel = /^#{1,6}\s+\S|^[A-Za-z][A-Za-z0-9 /_-]{2,60}:$/m.test(normalized);
113
+
114
+ // This is the key path for real completion reports from agents: multiple
115
+ // human-readable sections plus bullets. Those reports are already the final
116
+ // answer and must not be collapsed into "Files changed: Validation: ...".
117
+ if (hasCue && headingCount >= 2 && (itemCount >= 1 || rawLines.length >= 5)) return true;
118
+
119
+ // Allow slightly shorter reports with an explicit root cause / validation shape.
120
+ if (hasCue && headingCount >= 1 && itemCount >= 2 && hasSectionLabel) return true;
121
+
122
+ return false;
123
+ }
124
+
125
+ function getPassThroughHumanFinalReport(text) {
126
+ const normalized = normalizeReportLineEndings(text);
127
+ if (!normalized) return null;
128
+ return isLikelyHumanFinalReport(normalized) ? normalized : null;
129
+ }
130
+
74
131
  function isGenericOrTrivial(text) {
75
132
  const normalized = normalizeCompletionText(text)?.toLowerCase().replace(/\s+/g, ' ').trim();
76
133
  if (!normalized) return true;
@@ -205,6 +262,80 @@ function upperFirst(text) {
205
262
  return text.charAt(0).toUpperCase() + text.slice(1);
206
263
  }
207
264
 
265
+ function extractExplicitTechnicalTail(text) {
266
+ const normalized = normalizeCompletionText(text);
267
+ if (!normalized) return null;
268
+
269
+ const match = EXPLICIT_TECHNICAL_MARKER_RE.exec(normalized);
270
+ if (!match || typeof match.index !== 'number' || match.index <= 0) return null;
271
+
272
+ const lead = normalizeCompletionText(normalized.slice(0, match.index));
273
+ const technicalTail = normalizeCompletionText(normalized.slice(match.index + match[0].length));
274
+ if (!lead || !technicalTail) return null;
275
+
276
+ return { lead, technicalTail };
277
+ }
278
+
279
+ function extractStructuredSummarySections(text) {
280
+ const normalized = normalizeCompletionText(text);
281
+ if (!normalized) return null;
282
+
283
+ const cleaned = cleanMarkdown(normalized).replace(/\r\n?/g, '\n').trim();
284
+ if (!cleaned) return null;
285
+
286
+ const humanMatch = HUMAN_SUMMARY_SECTION_RE.exec(cleaned);
287
+ const technicalMatch = TECHNICAL_DETAILS_SECTION_RE.exec(cleaned);
288
+ if (!humanMatch && !technicalMatch) return null;
289
+
290
+ let summary = null;
291
+ let technical = null;
292
+
293
+ if (humanMatch) {
294
+ const summaryStart = humanMatch.index + humanMatch[0].length;
295
+ const summaryEnd = technicalMatch && technicalMatch.index > humanMatch.index
296
+ ? technicalMatch.index
297
+ : cleaned.length;
298
+ summary = normalizeCompletionText(cleaned.slice(summaryStart, summaryEnd));
299
+ }
300
+
301
+ if (technicalMatch) {
302
+ const technicalStart = technicalMatch.index + technicalMatch[0].length;
303
+ technical = normalizeCompletionText(cleaned.slice(technicalStart));
304
+ }
305
+
306
+ if (!summary && !technical) return null;
307
+ return { summary, technical };
308
+ }
309
+
310
+ function stripHumanSummaryLabel(text) {
311
+ const normalized = normalizeCompletionText(text);
312
+ if (!normalized) return null;
313
+
314
+ const sections = extractStructuredSummarySections(normalized);
315
+ if (sections?.summary) return normalizeCompletionText(sections.summary);
316
+ return normalizeCompletionText(normalized.replace(HUMAN_SUMMARY_LABEL_RE, ''));
317
+ }
318
+
319
+ function normalizeTechnicalDetailLine(text) {
320
+ const normalized = normalizeCompletionText(text);
321
+ if (!normalized) return null;
322
+
323
+ const sections = extractStructuredSummarySections(normalized);
324
+ const source = normalizeCompletionText(sections?.technical || normalized);
325
+ if (!source) return null;
326
+
327
+ const lines = prepareLines(source)
328
+ .map(line => line
329
+ .replace(HUMAN_SUMMARY_LABEL_RE, '')
330
+ .replace(TECHNICAL_DETAILS_LABEL_RE, '')
331
+ .replace(/^[-*•]\s+/, '')
332
+ .trim())
333
+ .filter(Boolean);
334
+
335
+ const compact = normalizeCompletionText(lines.join(' '));
336
+ return compact || null;
337
+ }
338
+
208
339
  function looksLikeRawPayloadText(text) {
209
340
  const normalized = normalizeCompletionText(text);
210
341
  if (!normalized) return false;
@@ -409,6 +540,107 @@ function cleanTechnicalFragment(text) {
409
540
  return cleaned || null;
410
541
  }
411
542
 
543
+ function cleanLeadForHumanSummary(text) {
544
+ const normalized = normalizeCompletionText(text);
545
+ if (!normalized) return null;
546
+
547
+ const cleaned = replaceTechnicalPhrases(
548
+ cleanMarkdown(normalized)
549
+ .replace(/\bsaved\s+[a-z0-9_]+(?:\/[a-z0-9_]+)+\b/gi, 'your saved progress')
550
+ .replace(/\b([a-z][A-Za-z0-9_]*[A-Z][A-Za-z0-9_]*)\b/g, (_, token) => humanizeCamelToken(token))
551
+ .replace(/\b([a-z0-9]+_[a-z0-9_]+)\b/g, token => token.replace(/_/g, ' ')),
552
+ )
553
+ .replace(/\brepeating the completed one\b/gi, 'repeating the one you already finished')
554
+ .replace(/\s+,/g, ',')
555
+ .replace(/\s+\./g, '.')
556
+ .replace(/\s+/g, ' ')
557
+ .trim();
558
+
559
+ return cleaned || null;
560
+ }
561
+
562
+ function formatMixedTechnicalSubject(subject) {
563
+ let cleaned = normalizeCompletionText(subject);
564
+ if (!cleaned) return 'the missing data';
565
+
566
+ cleaned = replaceTechnicalPhrases(
567
+ cleanMarkdown(cleaned)
568
+ .replace(/\bhealth auto export\b/gi, 'source export')
569
+ .replace(/\bworkouts?\.json\b/gi, 'the export')
570
+ .replace(/\b([a-z][A-Za-z0-9_]*[A-Z][A-Za-z0-9_]*)\b/g, (_, token) => humanizeCamelToken(token))
571
+ .replace(/\b([a-z0-9]+_[a-z0-9_]+)\b/g, token => token.replace(/_/g, ' ')),
572
+ )
573
+ .replace(/^the\s+(?!missing\b)/i, '')
574
+ .replace(/\s+/g, ' ')
575
+ .trim();
576
+
577
+ if (!cleaned) return 'the missing data';
578
+ if (/^the\s+missing\b/i.test(cleaned)) return cleaned;
579
+ if (/^missing\b/i.test(cleaned)) return `the ${cleaned}`;
580
+ if (/^(?:the|your|this|that)\b/i.test(cleaned)) return cleaned;
581
+ return `the ${cleaned}`;
582
+ }
583
+
584
+ function buildSourceSideHumanSentence(technicalTail) {
585
+ const normalized = cleanMarkdown(normalizeCompletionText(technicalTail) || '')
586
+ .replace(/\s+/g, ' ')
587
+ .trim();
588
+ if (!normalized) return null;
589
+
590
+ const hasSourceSide = /\bsource[- ]side\b/i.test(normalized);
591
+ const hasEmptySource = /\b(?:contains zero|count 0|empty|zero [a-z ]+ objects?|no [a-z ]+ to import)\b/i.test(normalized);
592
+ if (!hasSourceSide && !hasEmptySource) return null;
593
+
594
+ const subjectMatch = normalized.match(/\b(?:confirmed|found|verified|checked)\s+(the\s+missing\s+.+?)\s+is\s+source[- ]side\b/i)
595
+ || normalized.match(/\b(the\s+missing\s+.+?)\s+is\s+source[- ]side\b/i)
596
+ || normalized.match(/\b(?:confirmed|found|verified|checked)\s+(.+?)\s+is\s+source[- ]side\b/i)
597
+ || normalized.match(/\b(.+?)\s+is\s+source[- ]side\b/i);
598
+ const subject = formatMixedTechnicalSubject(subjectMatch?.[1] || 'missing data');
599
+
600
+ if (hasEmptySource) {
601
+ return `I also checked ${subject}, and the source export is empty right now, so there isn't anything new to import yet.`;
602
+ }
603
+
604
+ return `I also checked ${subject}, and this turned out to be a source-data issue rather than a new scheduler bug.`;
605
+ }
606
+
607
+ function buildMixedLeadExpectation(text) {
608
+ const normalized = cleanMarkdown(normalizeCompletionText(text) || '').toLowerCase();
609
+ if (!normalized) return null;
610
+ if (/\b(?:next|should|will)\b/.test(normalized)) return null;
611
+
612
+ if (/\b(?:planner|plan|scheduled session|session|progression|workout)\b/.test(normalized)) {
613
+ return 'The next plan should reflect that automatically.';
614
+ }
615
+ if (/\b(?:completion|summary|delivery|message|report)\b/.test(normalized)) {
616
+ return 'The next finished job should show that automatically.';
617
+ }
618
+ if (/\b(?:import|sync)\b/.test(normalized)) {
619
+ return 'The next sync should reflect that automatically.';
620
+ }
621
+ return 'The next run should reflect that automatically.';
622
+ }
623
+
624
+ function buildHumanSummaryFromMixedTechnicalText(rawText) {
625
+ const split = extractExplicitTechnicalTail(rawText);
626
+ if (!split) return null;
627
+
628
+ const cleanedLead = cleanLeadForHumanSummary(split.lead);
629
+ const leadSummary = cleanedLead
630
+ ? summarizeCompletionText(cleanedLead, { skipEmbeddedObject: true }) || summarizeProse(cleanedLead) || asSentence(cleanedLead)
631
+ : null;
632
+ if (!leadSummary) return null;
633
+
634
+ const sentences = [leadSummary];
635
+ const sourceSideSentence = buildSourceSideHumanSentence(split.technicalTail);
636
+ if (sourceSideSentence) sentences.push(sourceSideSentence);
637
+
638
+ const expectation = buildMixedLeadExpectation(sentences.join(' '));
639
+ if (expectation) sentences.push(expectation);
640
+
641
+ return truncateText(sentences.join(' '), MAX_DELIVERY_CHARS);
642
+ }
643
+
412
644
  function isTestOrValidationFragment(fragment) {
413
645
  const cleaned = normalizeCompletionText(fragment);
414
646
  if (!cleaned) return false;
@@ -578,11 +810,21 @@ export function humanizeCompletionText(value) {
578
810
  const raw = normalizeCompletionText(value);
579
811
  if (!raw) return null;
580
812
 
581
- const summarized = summarizeCompletionText(raw);
813
+ const passThroughReport = getPassThroughHumanFinalReport(raw);
814
+ if (passThroughReport) return passThroughReport;
815
+
816
+ const structuredSections = extractStructuredSummarySections(raw);
817
+ const summarySource = normalizeCompletionText(structuredSections?.summary || raw);
818
+ if (!summarySource) return null;
819
+
820
+ const mixedSummary = buildHumanSummaryFromMixedTechnicalText(summarySource);
821
+ if (mixedSummary) return mixedSummary;
822
+
823
+ const summarized = summarizeCompletionText(summarySource);
582
824
  if (!summarized) return null;
583
- if (!looksTechnicalCompletionSummary(raw, summarized)) return summarized;
825
+ if (!looksTechnicalCompletionSummary(summarySource, summarized)) return stripHumanSummaryLabel(summarized) || summarized;
584
826
 
585
- return buildHumanizedTechnicalSummary(raw, summarized) || summarized;
827
+ return buildHumanizedTechnicalSummary(summarySource, summarized) || summarized;
586
828
  }
587
829
 
588
830
  function summarizeChecklistTechnicalDetails(checklist, sha) {
@@ -615,29 +857,47 @@ function buildTechnicalDetailsText({ rawText, summaryText, completion } = {}) {
615
857
  const details = getCompletionTechnicalDetails(completion);
616
858
  const parts = [];
617
859
 
618
- const rawTechnical = raw && looksTechnicalCompletionSummary(raw, summary) && raw !== summary;
860
+ const rawSections = extractStructuredSummarySections(raw);
861
+ const splitRaw = extractExplicitTechnicalTail(raw);
862
+ const rawTechnicalSource = normalizeTechnicalDetailLine(rawSections?.technical || splitRaw?.technicalTail || raw);
863
+ const rawHasExplicitTechnical = Boolean(rawSections?.technical || splitRaw?.technicalTail);
864
+
865
+ const rawTechnical = Boolean(
866
+ rawTechnicalSource
867
+ && ((rawHasExplicitTechnical && rawTechnicalSource !== summary)
868
+ || (looksTechnicalCompletionSummary(rawTechnicalSource, summary) && rawTechnicalSource !== summary)),
869
+ );
619
870
  if (rawTechnical) {
620
- parts.push(truncateText(cleanMarkdown(raw).replace(/\s+/g, ' ').trim(), 260));
871
+ parts.push(truncateText(rawTechnicalSource, 260));
621
872
  }
622
873
 
623
874
  let completionDetailsAreTechnical = false;
624
875
  if (typeof details === 'string') {
625
- const normalized = normalizeCompletionText(details);
626
- completionDetailsAreTechnical = Boolean(normalized && looksTechnicalCompletionSummary(normalized, summary));
876
+ const detailSections = extractStructuredSummarySections(details);
877
+ const splitDetails = extractExplicitTechnicalTail(details);
878
+ const normalized = normalizeTechnicalDetailLine(detailSections?.technical || splitDetails?.technicalTail || details);
879
+ completionDetailsAreTechnical = Boolean(
880
+ normalized && (detailSections?.technical || splitDetails?.technicalTail || looksTechnicalCompletionSummary(normalized, summary)),
881
+ );
627
882
  if (normalized
628
883
  && !isInternalTransportNoiseText(normalized)
629
884
  && (completionDetailsAreTechnical || rawTechnical)
630
- && (!rawTechnical || normalized !== raw)) {
885
+ && (!rawTechnical || normalized !== rawTechnicalSource)) {
631
886
  parts.push(truncateText(normalized, 220));
632
887
  }
633
888
  } else if (details && typeof details === 'object') {
634
889
  const rawSummary = normalizeCompletionText(details.raw_summary);
635
- completionDetailsAreTechnical = Boolean(rawSummary && looksTechnicalCompletionSummary(rawSummary, summary));
636
- if (rawSummary
637
- && !isInternalTransportNoiseText(rawSummary)
890
+ const detailSummarySections = extractStructuredSummarySections(rawSummary);
891
+ const splitDetailSummary = extractExplicitTechnicalTail(rawSummary);
892
+ const technicalSummary = normalizeTechnicalDetailLine(detailSummarySections?.technical || splitDetailSummary?.technicalTail || rawSummary);
893
+ completionDetailsAreTechnical = Boolean(
894
+ technicalSummary && (detailSummarySections?.technical || splitDetailSummary?.technicalTail || looksTechnicalCompletionSummary(technicalSummary, summary)),
895
+ );
896
+ if (technicalSummary
897
+ && !isInternalTransportNoiseText(technicalSummary)
638
898
  && (completionDetailsAreTechnical || rawTechnical)
639
- && (!rawTechnical || rawSummary !== raw)) {
640
- parts.push(truncateText(cleanMarkdown(rawSummary).replace(/\s+/g, ' ').trim(), 220));
899
+ && (!rawTechnical || technicalSummary !== rawTechnicalSource)) {
900
+ parts.push(truncateText(technicalSummary, 220));
641
901
  }
642
902
  }
643
903
 
@@ -647,7 +907,7 @@ function buildTechnicalDetailsText({ rawText, summaryText, completion } = {}) {
647
907
  const unique = [];
648
908
  const seen = new Set();
649
909
  for (const part of parts) {
650
- const normalized = normalizeCompletionText(part);
910
+ const normalized = normalizeTechnicalDetailLine(part) || normalizeCompletionText(part);
651
911
  if (!normalized) continue;
652
912
  const key = normalized.toLowerCase();
653
913
  if (seen.has(key)) continue;
@@ -659,22 +919,39 @@ function buildTechnicalDetailsText({ rawText, summaryText, completion } = {}) {
659
919
  }
660
920
 
661
921
  function composeDeliveryText(summaryText, technicalDetailsText = null) {
662
- const summary = normalizeCompletionText(summaryText);
922
+ const summarySections = extractStructuredSummarySections(summaryText);
923
+ const summary = stripHumanSummaryLabel(summarySections?.summary || summaryText);
663
924
  if (!summary) return null;
664
- const technicalLines = Array.isArray(technicalDetailsText)
665
- ? technicalDetailsText.map(line => normalizeCompletionText(line)).filter(Boolean)
666
- : [];
925
+
926
+ const technicalCandidates = [];
927
+ if (summarySections?.technical) technicalCandidates.push(summarySections.technical);
928
+ if (Array.isArray(technicalDetailsText)) technicalCandidates.push(...technicalDetailsText);
929
+ else if (technicalDetailsText != null) technicalCandidates.push(technicalDetailsText);
930
+
931
+ const technicalLines = [];
932
+ const seen = new Set();
933
+ for (const candidate of technicalCandidates) {
934
+ const normalized = normalizeTechnicalDetailLine(candidate);
935
+ if (!normalized) continue;
936
+ const key = normalized.toLowerCase();
937
+ if (seen.has(key)) continue;
938
+ seen.add(key);
939
+ technicalLines.push(normalized);
940
+ }
941
+
667
942
  if (technicalLines.length > 0) {
668
943
  return `${summary}\n\nTechnical details:\n- ${technicalLines.join('\n- ')}`;
669
944
  }
670
- const technical = normalizeCompletionText(technicalDetailsText);
671
- return technical ? `${summary}\n\nTechnical details:\n- ${technical}` : summary;
945
+ return summary;
672
946
  }
673
947
 
674
948
  export function summarizeCompletionText(value, { skipEmbeddedObject = false } = {}) {
675
949
  const raw = normalizeCompletionText(value);
676
950
  if (!raw) return null;
677
951
 
952
+ const passThroughReport = getPassThroughHumanFinalReport(raw);
953
+ if (passThroughReport) return passThroughReport;
954
+
678
955
  if (!skipEmbeddedObject) {
679
956
  const parsed = extractEmbeddedCompletionObject(raw);
680
957
  if (parsed !== null) {
@@ -205,6 +205,19 @@ function setLabel(name, data) {
205
205
  return labels[name];
206
206
  }
207
207
 
208
+ function setLabelDone(name, data) {
209
+ const labels = mutateLabels((current) => {
210
+ current[name] = {
211
+ ...current[name],
212
+ ...data,
213
+ status: 'done',
214
+ updatedAt: new Date().toISOString(),
215
+ };
216
+ delete current[name].error;
217
+ });
218
+ return labels[name];
219
+ }
220
+
208
221
  // -- Gateway Calls --------------------------------------------
209
222
 
210
223
  /**
@@ -352,7 +365,17 @@ function getSessionJsonlPath(agent = 'main', sessionId) {
352
365
 
353
366
  function inspectSessionActivitySignal(sessionKey, sessionsStore) {
354
367
  if (!sessionKey || !sessionsStore?.[sessionKey]) {
355
- return { found: false, hasActivitySignal: false, messageCount: null, jsonlExists: false, hasTokens: false, updatedAtMs: null };
368
+ return {
369
+ found: false,
370
+ hasStartedSignal: false,
371
+ hasActivitySignal: false,
372
+ messageCount: null,
373
+ jsonlExists: false,
374
+ hasTokens: false,
375
+ updatedAtMs: null,
376
+ sessionStartedAtMs: null,
377
+ sessionId: null,
378
+ };
356
379
  }
357
380
 
358
381
  const agent = agentFromSessionKey(sessionKey) || 'main';
@@ -360,6 +383,9 @@ function inspectSessionActivitySignal(sessionKey, sessionsStore) {
360
383
  const jsonlPath = getSessionJsonlPath(agent, entry.sessionId);
361
384
  const jsonlExists = jsonlPath ? existsSync(jsonlPath) : false;
362
385
  const hasTokens = typeof entry.totalTokens === 'number' && entry.totalTokens > 0;
386
+ const sessionStartedAtMs = toTimestampMs(entry.sessionStartedAt || entry.startedAt);
387
+ const updatedAtMs = toTimestampMs(entry.updatedAt);
388
+ const hasStartedSignal = Boolean(entry.sessionId) || sessionStartedAtMs !== null || updatedAtMs !== null;
363
389
  let messageCount = null;
364
390
 
365
391
  try {
@@ -371,11 +397,14 @@ function inspectSessionActivitySignal(sessionKey, sessionsStore) {
371
397
 
372
398
  return {
373
399
  found: true,
400
+ hasStartedSignal,
374
401
  hasActivitySignal: jsonlExists || hasTokens || (typeof messageCount === 'number' && messageCount > 0),
375
402
  messageCount,
376
403
  jsonlExists,
377
404
  hasTokens,
378
- updatedAtMs: toTimestampMs(entry.updatedAt),
405
+ updatedAtMs,
406
+ sessionStartedAtMs,
407
+ sessionId: entry.sessionId || null,
379
408
  };
380
409
  }
381
410
 
@@ -385,12 +414,7 @@ function inspectSessionBootstrapFailure(sessionKey, sessionsStore, spawnedAtMs,
385
414
  }
386
415
 
387
416
  const ageMs = spawnedAtMs ? Date.now() - spawnedAtMs : Infinity;
388
- if (ageMs < startupGraceMs || ageMs > startupGraceMs * 2) {
389
- return { shouldResolve: false, reason: null, errorMsg: null };
390
- }
391
-
392
- const signal = inspectSessionActivitySignal(sessionKey, sessionsStore);
393
- if (signal.hasActivitySignal) {
417
+ if (ageMs < startupGraceMs) {
394
418
  return { shouldResolve: false, reason: null, errorMsg: null };
395
419
  }
396
420
 
@@ -403,22 +427,10 @@ function inspectSessionBootstrapFailure(sessionKey, sessionsStore, spawnedAtMs,
403
427
  };
404
428
  }
405
429
 
406
- if (signal.messageCount === 0) {
407
- return {
408
- shouldResolve: true,
409
- reason: 'session entered sessions store but never wrote transcript/history',
410
- errorMsg: 'spawn-failure: session entered sessions store but never wrote transcript/history',
411
- };
412
- }
413
-
414
- if (signal.updatedAtMs !== null && spawnedAtMs && signal.updatedAtMs <= spawnedAtMs + 5000) {
415
- return {
416
- shouldResolve: true,
417
- reason: 'session entered sessions store but never showed any activity',
418
- errorMsg: 'spawn-failure: session entered sessions store but never showed any activity',
419
- };
420
- }
421
-
430
+ // A Codex session can enter the sessions store before chat.history, JSONL, or
431
+ // token counters are written. Treat that as "still booting"; the watcher and
432
+ // job timeout own later failure handling. Only fail fast when the gateway has
433
+ // recorded an explicit lane error above.
422
434
  return { shouldResolve: false, reason: null, errorMsg: null };
423
435
  }
424
436
 
@@ -683,7 +695,7 @@ function quoteForSingleQuotedShell(value) {
683
695
  }
684
696
 
685
697
  /**
686
- * Schedule a one-shot delivery watcher shell job for a dispatch label.
698
+ * Schedule a quick-poll delivery watcher shell job for a dispatch label.
687
699
  * Used both for the initial watcher registration and SIGTERM handoffs.
688
700
  */
689
701
  function scheduleDeliveryWatcherJob({
@@ -704,13 +716,19 @@ function scheduleDeliveryWatcherJob({
704
716
  const watcherTimeoutS = Number(timeoutSeconds) + 120;
705
717
  const idleThresholdS = Number(idleThresholdSeconds) || 300;
706
718
  const sq = quoteForSingleQuotedShell;
707
- const watcherCmd = `DISPATCH_LABELS_PATH='${sq(LABELS_PATH)}' '${sq(process.execPath)}' '${sq(watcherPath)}' --label '${sq(label)}' --timeout ${watcherTimeoutS} --poll-interval 20 --idle-threshold ${idleThresholdS}`;
719
+ const watcherCmd =
720
+ `DISPATCH_LABELS_PATH='${sq(LABELS_PATH)}' ` +
721
+ `DISPATCH_INDEX_PATH='${sq(join(__dirname, 'index.mjs'))}' ` +
722
+ `'${sq(process.execPath)}' '${sq(watcherPath)}' ` +
723
+ `--label '${sq(label)}' --timeout ${watcherTimeoutS} ` +
724
+ `--poll-interval 20 --idle-threshold ${idleThresholdS} --once`;
708
725
 
709
726
  const nowUtc = new Date().toISOString().replace('T', ' ').slice(0, 19);
710
727
  const jobSpec = {
711
728
  name: `${agentBrand}-deliver:${label}${nameSuffix}`,
712
- schedule_kind: 'at',
713
- schedule_at: nowUtc,
729
+ schedule_kind: 'cron',
730
+ schedule_cron: config.deliver_watcher_cron || '* * * * *',
731
+ next_run_at: nowUtc,
714
732
  session_target: 'shell',
715
733
  payload_kind: 'shellCommand',
716
734
  payload_message: watcherCmd,
@@ -720,8 +738,7 @@ function scheduleDeliveryWatcherJob({
720
738
  delivery_guarantee: 'at-least-once',
721
739
  ttl_hours: config.deliver_watcher_ttl_hours ?? 48,
722
740
  overlap_policy: 'skip',
723
- run_timeout_ms: Math.max(watcherTimeoutS, 4 * 3600) * 1000
724
- + 420 * 1000,
741
+ run_timeout_ms: 120_000,
725
742
  delete_after_run: 1,
726
743
  origin: origin || 'system',
727
744
  };
@@ -1088,9 +1105,10 @@ async function cmdEnqueue(flags) {
1088
1105
  }
1089
1106
 
1090
1107
  // -- Register scheduler watcher for delivery ---------------
1091
- // Creates a one-shot shell job that runs watcher.mjs (blocks until session
1092
- // completes, outputs result). The scheduler's handleDelivery delivers with
1093
- // retry, alias resolution, and audit trail in scheduler.db.
1108
+ // Creates a quick-poll shell job that runs watcher.mjs once per tick. Empty
1109
+ // stdout means "still running" and advances the next tick without delivery.
1110
+ // Terminal stdout goes through the scheduler's handleDelivery with retry,
1111
+ // alias resolution, and audit trail in scheduler.db.
1094
1112
  // The watcher is the only final-delivery path for dispatched jobs.
1095
1113
  const sq = s => String(s).replace(/'/g, "'\\''");
1096
1114
  let schedulerWatcherOk = false;
@@ -1204,9 +1222,10 @@ async function cmdEnqueue(flags) {
1204
1222
 
1205
1223
  // -- Post-spawn verification (Fix 3) --------------------------------
1206
1224
  // Canary: poll sessions.json up to 3 times at 10s intervals to confirm the
1207
- // session appeared in the store. Non-fatal -- output is already written above.
1208
- // If the session never shows up, stderr gets a loud warning and ledger status
1209
- // is set to 'spawn-warning'. The watcher provides the definitive error path.
1225
+ // session appeared in the store. A session store entry with sessionId or
1226
+ // startedAt/sessionStartedAt is enough: long first turns may not flush JSONL,
1227
+ // token counts, or chat.history until the model call completes. The delivery
1228
+ // watcher owns later completion/failure handling.
1210
1229
  const SPAWN_POLL_MAX = 3;
1211
1230
  const SPAWN_POLL_DELAY_MS = 10_000;
1212
1231
  let spawnConfirmed = false;
@@ -1214,7 +1233,7 @@ async function cmdEnqueue(flags) {
1214
1233
  await sleep(SPAWN_POLL_DELAY_MS);
1215
1234
  const spawnStore = readSessionsStore(agent);
1216
1235
  const signal = inspectSessionActivitySignal(sessionKey, spawnStore);
1217
- if (signal.hasActivitySignal) {
1236
+ if (signal.hasStartedSignal || signal.hasActivitySignal) {
1218
1237
  spawnConfirmed = true;
1219
1238
  break;
1220
1239
  }
@@ -1972,7 +1991,7 @@ async function cmdDone(flags) {
1972
1991
  // Label was never registered (e.g. direct subagent spawn, not via enqueue).
1973
1992
  // This is not an error -- the work completed, the label just wasn't tracked.
1974
1993
  process.stderr.write(`[${BRAND}] warn: no session found for label "${label}" -- registering as done\n`);
1975
- setLabel(label, { status: 'done', summary, completion, ...(sha ? { sha } : {}) });
1994
+ setLabelDone(label, { summary, completion, ...(sha ? { sha } : {}) });
1976
1995
 
1977
1996
  // No watcher is polling for this label, so actively notify via the gateway
1978
1997
  // post office using delivery config from config.json as fallback target.
@@ -2001,8 +2020,7 @@ async function cmdDone(flags) {
2001
2020
  return;
2002
2021
  }
2003
2022
 
2004
- setLabel(label, {
2005
- status: 'done',
2023
+ setLabelDone(label, {
2006
2024
  summary,
2007
2025
  completion,
2008
2026
  ...(sha ? { sha } : {}),
@@ -684,6 +684,112 @@ function getJsonlMidTurnReason(sessionId, agentDir = 'main') {
684
684
  return null; // Last assistant entry appears to be a complete text reply -- safe to proceed
685
685
  }
686
686
 
687
+ /**
688
+ * Check the JSONL tail for a pending tool handoff without requiring recent
689
+ * file activity. Long-running tool calls can leave the transcript flat for
690
+ * minutes, so stale mtime alone is not enough to declare the agent stuck.
691
+ *
692
+ * @param {string} sessionId - Internal session UUID
693
+ * @param {string} agentDir - Agent directory (default: 'main')
694
+ * @returns {string|null} reason string if a tool handoff appears pending
695
+ */
696
+ function getJsonlPendingToolReason(sessionId, agentDir = 'main') {
697
+ const lastLines = readJsonlLastLines(sessionId, agentDir, 3);
698
+ if (!lastLines || lastLines.length === 0) return null;
699
+
700
+ const last = lastLines[lastLines.length - 1];
701
+
702
+ if (last?.role === 'assistant') {
703
+ const content = Array.isArray(last.content) ? last.content : [];
704
+ const toolUse = content.find(c => c?.type === 'tool_use');
705
+ if (toolUse) {
706
+ return `last assistant entry has tool_use (${toolUse.name || 'unknown'}) -- awaiting tool result`;
707
+ }
708
+ if (last.type === 'tool_use') {
709
+ return `last entry is tool_use (${last.name || 'unknown'}) -- awaiting tool result`;
710
+ }
711
+ }
712
+
713
+ if (last?.role === 'user') {
714
+ const content = Array.isArray(last.content) ? last.content : [];
715
+ if (content.some(c => c?.type === 'tool_result')) {
716
+ return 'last entry is tool_result (tool executed, awaiting assistant reply)';
717
+ }
718
+ }
719
+
720
+ if (last?.type === 'tool_result') {
721
+ return 'last entry is tool_result (tool executed, awaiting assistant reply)';
722
+ }
723
+
724
+ return null;
725
+ }
726
+
727
+ function parseTimestampMs(value) {
728
+ if (!value) return null;
729
+ if (typeof value === 'number') {
730
+ return Number.isFinite(value) ? value : null;
731
+ }
732
+ if (value instanceof Date) {
733
+ const timestamp = value.getTime();
734
+ return Number.isFinite(timestamp) ? timestamp : null;
735
+ }
736
+ const parsed = Date.parse(value);
737
+ return Number.isFinite(parsed) ? parsed : null;
738
+ }
739
+
740
+ /**
741
+ * Detect an agent session that has stopped making progress even though the
742
+ * watcher process itself is still alive and writing lastPing.
743
+ *
744
+ * This closes the failure mode where OpenClaw's Codex app-server retires a
745
+ * timed-out turn, but dispatch status keeps reporting "running" because the
746
+ * delivery watcher is still polling.
747
+ */
748
+ function getRunningSessionStallReason(status, thresholdMs) {
749
+ if (!status?.sessionKey) return null;
750
+
751
+ const sessionAgent = status.sessionKey.split(':')[1] || 'main';
752
+ const entry = getSessionStoreEntry(status.sessionKey);
753
+ if (!entry) return null;
754
+
755
+ const sessionId = entry.sessionId || null;
756
+ const now = Date.now();
757
+ const activityTimes = [
758
+ parseTimestampMs(entry.updatedAt),
759
+ parseTimestampMs(entry.lastActivityAt),
760
+ parseTimestampMs(entry.sessionStartedAt),
761
+ parseTimestampMs(entry.startedAt),
762
+ ].filter(t => typeof t === 'number');
763
+
764
+ const jsonlMtime = sessionId ? getSessionJsonlMtime(sessionId, sessionAgent) : null;
765
+ if (typeof jsonlMtime === 'number') activityTimes.push(jsonlMtime);
766
+
767
+ if (typeof status?.liveness?.ageMs === 'number' && status.liveness.ageMs < thresholdMs) {
768
+ return null;
769
+ }
770
+
771
+ const lastActivityMs = activityTimes.length ? Math.max(...activityTimes) : null;
772
+ if (lastActivityMs !== null && now - lastActivityMs < thresholdMs) {
773
+ return null;
774
+ }
775
+
776
+ const pendingToolReason = sessionId ? getJsonlPendingToolReason(sessionId, sessionAgent) : null;
777
+ if (pendingToolReason) {
778
+ process.stderr.write(
779
+ `[watcher] ${status.label || 'session'} stale telemetry but pending tool handoff detected: ${pendingToolReason}\n`
780
+ );
781
+ return null;
782
+ }
783
+
784
+ const idleMinutes = lastActivityMs === null
785
+ ? Math.ceil(thresholdMs / 60000)
786
+ : Math.max(1, Math.floor((now - lastActivityMs) / 60000));
787
+ return (
788
+ `agent session stalled: no session/jsonl activity for ~${idleMinutes}min ` +
789
+ `while delivery watcher remained alive; likely app-server turn retired or stopped producing events`
790
+ );
791
+ }
792
+
687
793
  /**
688
794
  * Read the last assistant entry's stop_reason from the session JSONL.
689
795
  * Returns the stop_reason string (e.g. 'end_turn', 'tool_use') or null if unavailable.
@@ -754,6 +860,7 @@ function markLabelError(label, errorSummary) {
754
860
  updateExistingLabel(label, (entry) => {
755
861
  if (entry.status === 'done') return false;
756
862
  entry.status = 'error';
863
+ entry.error = errorSummary || 'failed without result';
757
864
  entry.summary = errorSummary || 'failed without result';
758
865
  });
759
866
  } catch (e) {
@@ -761,6 +868,8 @@ function markLabelError(label, errorSummary) {
761
868
  }
762
869
  }
763
870
 
871
+ let exitZeroOnTerminal = false;
872
+
764
873
  /**
765
874
  * Format and output the delivery message, then exit 0.
766
875
  * Also marks the label as done in labels.json before exiting.
@@ -794,7 +903,7 @@ function deliverResult(label, lastReply, fallbackSummary, completionPayload = nu
794
903
  `**Error:** ${stderr || 'non-zero exit'}\n\n` +
795
904
  `Job marked as \`error\`. The agent may have reported done without completing the actual work.\n`
796
905
  );
797
- process.exit(1);
906
+ process.exit(exitZeroOnTerminal ? 0 : 1);
798
907
  }
799
908
  }
800
909
  } catch (loadErr) {
@@ -816,10 +925,17 @@ function deliverResult(label, lastReply, fallbackSummary, completionPayload = nu
816
925
  ? completion.deliveryText.slice(0, maxLen) + '\n\n..[truncated]'
817
926
  : completion.deliveryText;
818
927
  process.stdout.write(`🌶️ *dispatch* [${label}] completed:\n\n${reply}\n`);
819
- } else {
820
- process.stderr.write(`[watcher] [${label}] completion delivery suppressed (no meaningful reply or summary)\n`);
928
+ process.exit(0);
821
929
  }
822
- process.exit(0);
930
+
931
+ const failureSummary = 'completed without a clean user-facing completion';
932
+ process.stderr.write(`[watcher] [${label}] completion delivery suppressed (no meaningful reply or summary)\n`);
933
+ markLabelError(label, failureSummary);
934
+ process.stdout.write(
935
+ `⚠️ dispatch [${label}] completed, but no clean user-facing completion was captured. ` +
936
+ `Internal diagnostics were suppressed; check scheduler run logs for details.\n`
937
+ );
938
+ process.exit(exitZeroOnTerminal ? 0 : 1);
823
939
  }
824
940
 
825
941
  function emitInterruptedOutcome(label, summary, result = null) {
@@ -829,12 +945,12 @@ function emitInterruptedOutcome(label, summary, result = null) {
829
945
  `⚠️ dispatch [${label}] session went idle before completing -- work may be incomplete` +
830
946
  `${formatDiagnosticSnippet(result?.diagnosticReply || result?.lastReply || null)}\n`
831
947
  );
832
- process.exit(1);
948
+ process.exit(exitZeroOnTerminal ? 0 : 1);
833
949
  }
834
950
 
835
951
  function emitTimeoutOutcome(label, message, result = null) {
836
952
  process.stdout.write(`${message}${formatDiagnosticSnippet(result?.diagnosticReply || result?.lastReply || null)}\n`);
837
- process.exit(1);
953
+ process.exit(exitZeroOnTerminal ? 0 : 1);
838
954
  }
839
955
 
840
956
  // -- Watcher heartbeat interval ref --------------------------------------
@@ -869,6 +985,8 @@ const flags = parseFlags(process.argv.slice(2));
869
985
  const label = flags.label;
870
986
  const timeoutS = parseInt(flags.timeout || '600', 10);
871
987
  const pollS = parseInt(flags['poll-interval'] || '20', 10);
988
+ const once = flags.once === true || flags.once === 'true';
989
+ exitZeroOnTerminal = once;
872
990
 
873
991
  // How long a session must be idle before we proactively check result
874
992
  const IDLE_RESULT_CHECK_MS = 60000;
@@ -878,6 +996,144 @@ if (!label) {
878
996
  process.exit(2);
879
997
  }
880
998
 
999
+ function touchWatcherPing(label) {
1000
+ updateExistingLabel(label, (entry) => {
1001
+ if (entry.status !== 'running') return false;
1002
+ entry.lastPing = new Date().toISOString();
1003
+ });
1004
+ }
1005
+
1006
+ function markWatcherPending(label, reason = 'target still running') {
1007
+ process.stderr.write(`[watcher] WATCHER_PENDING label=${label} reason=${reason}\n`);
1008
+ process.exit(0);
1009
+ }
1010
+
1011
+ function clearWatcherRetryAfter(label) {
1012
+ updateExistingLabel(label, (entry) => {
1013
+ if (!entry.watcherRetryAfter) return false;
1014
+ delete entry.watcherRetryAfter;
1015
+ });
1016
+ }
1017
+
1018
+ function handleOnce529(label, errorMsg) {
1019
+ const labels = loadLabels();
1020
+ const entry = labels[label] || {};
1021
+ const retryCount = getRetryCount(label);
1022
+
1023
+ if (retryCount >= MAX_529_RETRIES) {
1024
+ markLabelError(label, `max_retries_exceeded (${retryCount}x 529): ${errorMsg}`);
1025
+ process.stdout.write(
1026
+ `🌶️ *dispatch* [${label}] failed after ${MAX_529_RETRIES} retries (529 overload)\n` +
1027
+ `Error: ${errorMsg}\n`
1028
+ );
1029
+ process.exit(0);
1030
+ }
1031
+
1032
+ const retryAfterMs = parseTimestampMs(entry.watcherRetryAfter);
1033
+ if (!retryAfterMs) {
1034
+ const retryResult = attempt529Retry(label, retryCount, errorMsg);
1035
+ if (!retryResult.retry) return handleOnce529(label, errorMsg);
1036
+ updateExistingLabel(label, (current) => {
1037
+ current.watcherRetryAfter = new Date(Date.now() + retryResult.delayMs).toISOString();
1038
+ });
1039
+ markWatcherPending(label, `529 retry scheduled for future tick (${retryResult.delayMs / 1000}s)`);
1040
+ }
1041
+
1042
+ if (Date.now() < retryAfterMs) {
1043
+ markWatcherPending(label, '529 retry backoff active');
1044
+ }
1045
+
1046
+ if (respawnSession(label)) {
1047
+ clearWatcherRetryAfter(label);
1048
+ markWatcherPending(label, '529 retry dispatched');
1049
+ }
1050
+
1051
+ markLabelError(label, `529 retry failed -- could not respawn session: ${errorMsg}`);
1052
+ process.stdout.write(
1053
+ `🌶️ *dispatch* [${label}] 529 retry failed -- could not respawn session\n` +
1054
+ `Error: ${errorMsg}\n`
1055
+ );
1056
+ process.exit(0);
1057
+ }
1058
+
1059
+ function runOnceAndExit() {
1060
+ try {
1061
+ touchWatcherPing(label);
1062
+ } catch {
1063
+ // Best-effort -- a quick-poll tick must not fail because heartbeat metadata raced.
1064
+ }
1065
+
1066
+ const status = dispatch('status', ['--label', label]);
1067
+ if (!status?.ok) {
1068
+ markWatcherPending(label, 'status unavailable');
1069
+ }
1070
+
1071
+ if (status.status === 'error') {
1072
+ const errorMsg = status.error || status.summary || '';
1073
+ if (is529Error(errorMsg)) {
1074
+ handleOnce529(label, errorMsg);
1075
+ }
1076
+ }
1077
+
1078
+ if (status.status !== 'running') {
1079
+ const terminalResult = dispatch('result', ['--label', label]);
1080
+ const terminalCompletion = terminalResult?.completion || status?.completion || null;
1081
+
1082
+ if (status.status === 'done') {
1083
+ const currentRetryCount = getRetryCount(label);
1084
+ if (currentRetryCount > 0) setRetryCount(label, 0);
1085
+ const gwRetryCount = getGwRestartRetryCount(label);
1086
+ if (gwRetryCount > 0) setGwRestartRetryCount(label, 0);
1087
+ deliverResult(label, terminalResult?.lastReply, status.summary, terminalCompletion);
1088
+ }
1089
+
1090
+ if (status.status === 'interrupted') {
1091
+ emitInterruptedOutcome(label, status.summary, terminalResult);
1092
+ }
1093
+
1094
+ const summary = status.error || status.summary || `terminal failure (${status.status || 'unknown'})`;
1095
+ markLabelError(label, summary);
1096
+ process.stdout.write(`🌶️ *dispatch* [${label}] failed\nSummary: ${summary}\n`);
1097
+ process.exit(0);
1098
+ }
1099
+
1100
+ if (status.sessionKey) {
1101
+ const entry = getSessionStoreEntry(status.sessionKey);
1102
+ const sessionId = entry?.sessionId || null;
1103
+ const sessionAgent = status.sessionKey.split(':')[1] || 'main';
1104
+ const terminalJsonlReply = sessionId ? getSessionTerminalReply(sessionId, sessionAgent) : null;
1105
+ if (sessionId && terminalJsonlReply && isSessionCleanlyFinished(sessionId, sessionAgent)) {
1106
+ const result = dispatch('result', ['--label', label]);
1107
+ deliverResult(label, result?.lastReply || terminalJsonlReply, 'completed (stop_reason=end_turn)', result?.completion || null);
1108
+ }
1109
+ }
1110
+
1111
+ const ageMs = status.liveness?.ageMs;
1112
+ if (ageMs != null && ageMs >= IDLE_RESULT_CHECK_MS) {
1113
+ const result = dispatch('result', ['--label', label]);
1114
+ if (result?.lastReply || hasCompletionSignal(result?.completion)) {
1115
+ deliverResult(label, result?.lastReply || null, null, result?.completion || null);
1116
+ }
1117
+
1118
+ const stallReason = getRunningSessionStallReason(status, IDLE_RESULT_CHECK_MS);
1119
+ if (stallReason) {
1120
+ process.stderr.write(`[watcher] [${label}] ${stallReason}\n`);
1121
+ markLabelError(label, stallReason);
1122
+ process.stdout.write(
1123
+ `❌ *dispatch* [${label}] failed\n` +
1124
+ `Summary: ${stallReason}\n`
1125
+ );
1126
+ process.exit(0);
1127
+ }
1128
+ }
1129
+
1130
+ markWatcherPending(label);
1131
+ }
1132
+
1133
+ if (once) {
1134
+ runOnceAndExit();
1135
+ }
1136
+
881
1137
  // -- Start heartbeat -----------------------------------------------------
882
1138
  // Write lastPing to labels.json every PING_INTERVAL_MS while the session is
883
1139
  // still running. The watchdog guard in index.mjs reads lastPing to know this
@@ -1238,6 +1494,17 @@ while (Date.now() < deadline) {
1238
1494
  if (result?.lastReply || hasCompletionSignal(result?.completion)) {
1239
1495
  deliverResult(label, result?.lastReply || null, null, result?.completion || null);
1240
1496
  }
1497
+
1498
+ const stallReason = getRunningSessionStallReason(status, IDLE_RESULT_CHECK_MS);
1499
+ if (stallReason) {
1500
+ process.stderr.write(`[watcher] [${label}] ${stallReason}\n`);
1501
+ markLabelError(label, stallReason);
1502
+ process.stdout.write(
1503
+ `❌ *dispatch* [${label}] failed\n` +
1504
+ `Summary: ${stallReason}\n`
1505
+ );
1506
+ process.exit(1);
1507
+ }
1241
1508
  }
1242
1509
 
1243
1510
 
@@ -1095,6 +1095,25 @@ export async function executeMain(job, ctx, deps) {
1095
1095
 
1096
1096
  // -- Strategy: Shell -----------------------------------------
1097
1097
 
1098
+ function isCompletionDeliveryWatcherJob(job) {
1099
+ return /^(?:dispatch|chilisaus)-deliver:/.test(String(job?.name || ''));
1100
+ }
1101
+
1102
+ function isCompletionWatcherPendingTick(shellResult) {
1103
+ return !(shellResult.stdout || '').trim()
1104
+ && /\bWATCHER_PENDING\b/.test(shellResult.stderr || '');
1105
+ }
1106
+
1107
+ function buildCompletionWatcherNoPayloadMessage(job, shellResult) {
1108
+ const statusLabel = shellResult.status === 'ok'
1109
+ ? 'completed without a deliverable result'
1110
+ : `failed before producing a deliverable result${shellResult.errorMessage ? ` (${shellResult.errorMessage})` : ''}`;
1111
+ return [
1112
+ `⚠️ Completion delivery watcher for ${job.name} ${statusLabel}.`,
1113
+ 'No internal diagnostics were delivered as the completion message; check the scheduler run logs for stderr/details.',
1114
+ ].join('\n');
1115
+ }
1116
+
1098
1117
  export async function executeShell(job, ctx, deps) {
1099
1118
  const { runShellCommand, normalizeShellResult, log } = deps;
1100
1119
  const result = makeDefaultResult();
@@ -1129,18 +1148,61 @@ export async function executeShell(job, ctx, deps) {
1129
1148
  shell_stderr_bytes: shellResult.stderrBytes,
1130
1149
  };
1131
1150
 
1132
- // Shell delivery logic: announce-always sends on all results, announce sends on error only
1133
- const announcePayload = shellResult.deliveryText.trim() ? shellResult.deliveryText : shellResult.errorMessage;
1134
- if (job.delivery_mode === 'announce-always' && announcePayload) {
1135
- const prefix = shellResult.status === 'ok' ? '' : `\u26a0\ufe0f Shell job failed: ${job.name}\n\n`;
1136
- result.deliveryOverride = `${prefix}${announcePayload}`;
1137
- } else if (job.delivery_mode === 'announce' && shellResult.status !== 'ok' && announcePayload) {
1138
- result.deliveryOverride = announcePayload;
1151
+ if (isCompletionDeliveryWatcherJob(job)) {
1152
+ const watcherStdout = (shellResult.stdout || '').trim();
1153
+ const watcherStderr = (shellResult.stderr || '').trim();
1154
+
1155
+ if (isCompletionWatcherPendingTick(shellResult)) {
1156
+ result.status = 'skipped';
1157
+ result.summary = 'Completion delivery watcher pending; target session is still running';
1158
+ result.content = '';
1159
+ result.errorMessage = null;
1160
+ result.idemAction = 'release';
1161
+ result.skipDelivery = true;
1162
+ } else if (watcherStdout) {
1163
+ // Completion watcher stdout is the only user-facing contract. Stderr is
1164
+ // diagnostics-only and must never be repackaged as a "successful" final
1165
+ // completion if the watcher suppressed the real payload.
1166
+ result.summary = watcherStdout;
1167
+ result.content = watcherStdout;
1168
+ if (['announce', 'announce-always'].includes(job.delivery_mode)) {
1169
+ result.deliveryOverride = watcherStdout;
1170
+ } else {
1171
+ result.skipDelivery = true;
1172
+ }
1173
+ } else {
1174
+ const noPayloadMessage = buildCompletionWatcherNoPayloadMessage(job, shellResult);
1175
+ result.status = 'error';
1176
+ result.summary = noPayloadMessage;
1177
+ result.errorMessage = 'Completion delivery watcher produced no user-facing stdout payload';
1178
+ result.content = noPayloadMessage;
1179
+ if (['announce', 'announce-always'].includes(job.delivery_mode)) {
1180
+ result.deliveryOverride = noPayloadMessage;
1181
+ } else {
1182
+ result.skipDelivery = true;
1183
+ }
1184
+ log('warn', `Completion watcher produced no deliverable stdout: ${job.name}`, {
1185
+ runId: ctx.run.id,
1186
+ shellStatus: shellResult.status,
1187
+ exitCode: shellResult.exitCode,
1188
+ stderrExcerpt: watcherStderr.slice(0, 500),
1189
+ skippedOrDisabled: /\b(?:skipped|disabled)\b/i.test(watcherStderr),
1190
+ });
1191
+ }
1139
1192
  } else {
1140
- result.skipDelivery = true;
1193
+ // Shell delivery logic: announce-always sends on all results, announce sends on error only
1194
+ const announcePayload = shellResult.deliveryText.trim() ? shellResult.deliveryText : shellResult.errorMessage;
1195
+ if (job.delivery_mode === 'announce-always' && announcePayload) {
1196
+ const prefix = shellResult.status === 'ok' ? '' : `\u26a0\ufe0f Shell job failed: ${job.name}\n\n`;
1197
+ result.deliveryOverride = `${prefix}${announcePayload}`;
1198
+ } else if (job.delivery_mode === 'announce' && shellResult.status !== 'ok' && announcePayload) {
1199
+ result.deliveryOverride = announcePayload;
1200
+ } else {
1201
+ result.skipDelivery = true;
1202
+ }
1141
1203
  }
1142
1204
 
1143
- log('info', `Shell ${shellResult.status}: ${job.name}`, {
1205
+ log('info', `Shell ${result.status}: ${job.name}`, {
1144
1206
  runId: ctx.run.id,
1145
1207
  exitCode: shellResult.exitCode,
1146
1208
  signal: shellResult.signal,
@@ -1156,11 +1218,16 @@ export async function executeAgent(job, ctx, deps) {
1156
1218
  const {
1157
1219
  waitForGateway, updateRunSession, setAgentStatus,
1158
1220
  buildJobPrompt, runAgentTurnWithActivityTimeout,
1221
+ // Sanctioned isolated dispatch primitive. Falls back to the activity-aware
1222
+ // runner when callers (e.g. tests) wire only the older name -- both helpers
1223
+ // share the same HTTP-only contract, no subprocess spawn.
1224
+ runIsolatedAgentTurn,
1159
1225
  updateContextSummary, releaseDispatch, releaseIdempotencyKey,
1160
1226
  updateJob, matchesSentinel, detectTransientError,
1161
1227
  listSessions,
1162
1228
  sqliteNow, log,
1163
1229
  } = deps;
1230
+ const dispatchAgentTurn = runIsolatedAgentTurn || runAgentTurnWithActivityTimeout;
1164
1231
  const result = makeDefaultResult();
1165
1232
 
1166
1233
  // Gateway health check
@@ -1254,7 +1321,12 @@ export async function executeAgent(job, ctx, deps) {
1254
1321
  }
1255
1322
  }
1256
1323
 
1257
- const turnResult = await runAgentTurnWithActivityTimeout({
1324
+ // Isolated dispatch primitive: HTTP-only chat completions call. The
1325
+ // scheduler must never fork a sibling `openclaw` process to spawn an
1326
+ // isolated session -- that variant has historically SIGTERM'd the
1327
+ // launchd-tracked gateway parent and orphaned a node process on port
1328
+ // 18789 (see ISOLATED_DISPATCH_PRIMITIVE in gateway.js).
1329
+ const turnResult = await dispatchAgentTurn({
1258
1330
  message: prompt,
1259
1331
  agentId: job.agent_id || 'main',
1260
1332
  sessionKey,
package/dispatcher.js CHANGED
@@ -51,7 +51,8 @@ import {
51
51
  import { buildRetrievalContext } from './retrieval.js';
52
52
  import { upsertAgent, setAgentStatus } from './agents.js';
53
53
  import {
54
- runAgentTurnWithActivityTimeout, sendSystemEvent, getAllSubAgentSessions, listSessions,
54
+ runAgentTurnWithActivityTimeout, runIsolatedAgentTurn,
55
+ sendSystemEvent, getAllSubAgentSessions, listSessions,
55
56
  deliverMessage, checkGatewayHealth, waitForGateway, resolveDeliveryAlias,
56
57
  applyAuthProfileToSessionStore,
57
58
  syncAuthStoreToSession,
@@ -306,6 +307,10 @@ function buildDispatchDeps() {
306
307
  // Agent
307
308
  waitForGateway, updateRunSession, setAgentStatus,
308
309
  buildJobPrompt, runAgentTurnWithActivityTimeout,
310
+ // Isolated cron-dispatch primitive: HTTP-only wrapper around the
311
+ // chat-completions API; never forks a sibling openclaw process that
312
+ // could SIGTERM the launchd-tracked gateway parent.
313
+ runIsolatedAgentTurn,
309
314
  updateContextSummary, releaseIdempotencyKey,
310
315
  matchesSentinel, detectTransientError,
311
316
  listSessions,
package/gateway.js CHANGED
@@ -9,6 +9,22 @@ const GATEWAY_URL = process.env.OPENCLAW_GATEWAY_URL || 'http://127.0.0.1:18789'
9
9
  const HOME_DIR = process.env.HOME || homedir();
10
10
  export const TELEGRAM_MAX_MESSAGE_LENGTH = 4096;
11
11
 
12
+ // -- Isolated dispatch primitive contract --------------------
13
+ //
14
+ // Cron jobs with session_target=isolated must reach the gateway via the
15
+ // public HTTP API only. Forking a sibling `openclaw` process to spawn the
16
+ // session is rejected: in production that primitive has SIGTERM'd the
17
+ // launchd-tracked gateway parent (the child inherits the parent's listening
18
+ // socket on port 18789 and the parent dies), leaving an orphan node process
19
+ // holding the port. See rh-bot.lan zombie-cascade incident report.
20
+ //
21
+ // runIsolatedAgentTurn is the only sanctioned dispatch primitive for
22
+ // session_target=isolated cron jobs. It MUST NOT spawn, fork, or exec any
23
+ // child process. Any future change that needs subprocess execution belongs
24
+ // behind a different, explicitly-named helper so reviewers can keep this
25
+ // contract intact.
26
+ export const ISOLATED_DISPATCH_PRIMITIVE = 'http-chat-completions';
27
+
12
28
  let _cachedToken;
13
29
  let _tokenLoaded = false;
14
30
 
@@ -246,6 +262,29 @@ export async function runAgentTurnWithActivityTimeout(opts) {
246
262
  }
247
263
  }
248
264
 
265
+ // -- Isolated dispatch primitive -----------------------------
266
+
267
+ /**
268
+ * Sanctioned dispatch primitive for session_target=isolated cron jobs.
269
+ *
270
+ * This is a thin wrapper around runAgentTurnWithActivityTimeout that names
271
+ * the contract: HTTP-only request to the gateway, no child process spawn.
272
+ * The scheduler routes every session_target=isolated job through this
273
+ * helper so the no-fork invariant is reviewable at one call site and
274
+ * testable in isolation (see the no-subprocess regression test in test.js).
275
+ *
276
+ * Why a named wrapper instead of calling runAgentTurnWithActivityTimeout
277
+ * directly: the dispatch primitive is the load-bearing surface that the
278
+ * rh-bot.lan zombie-on-port outage cascaded through. A named entry point
279
+ * gives operators and reviewers a single grep target ("runIsolatedAgentTurn")
280
+ * to audit the no-spawn invariant.
281
+ *
282
+ * Accepts the same options as runAgentTurnWithActivityTimeout.
283
+ */
284
+ export async function runIsolatedAgentTurn(opts) {
285
+ return await runAgentTurnWithActivityTimeout(opts);
286
+ }
287
+
249
288
  // -- System Events (main session) ----------------------------
250
289
 
251
290
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openclaw-scheduler",
3
- "version": "0.2.5",
3
+ "version": "0.2.6",
4
4
  "description": "SQLite-backed job scheduler and workflow engine for OpenClaw agents",
5
5
  "type": "module",
6
6
  "main": "./index.js",