openclaw-scheduler 0.2.5 → 0.2.7

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.
@@ -291,8 +291,8 @@ No manual token configuration needed on a standard OpenClaw install.
291
291
 
292
292
  When `--deliver-to` is set, dispatch registers a **scheduler watcher job**
293
293
  after dispatching the session. The watcher polls the session result every
294
- minute until the agent produces a reply, then delivers via the scheduler's
295
- `handleDelivery` pipeline.
294
+ minute until the agent sends the structured `done` completion signal, then
295
+ delivers via the scheduler's `handleDelivery` pipeline.
296
296
 
297
297
  ```
298
298
  dispatch enqueue --deliver-to <telegram-user-id>
@@ -316,6 +316,20 @@ dispatch enqueue --deliver-to <telegram-user-id>
316
316
  Exit 1 with no output = retry on next cron tick (no spam — `announce-always`
317
317
  only delivers when `output.trim()` is truthy).
318
318
 
319
+ Quiet sessions are treated conservatively. The watcher does not mark a running
320
+ job failed just because `sessions.json` or the JSONL transcript has been quiet
321
+ for 60 seconds. For high/xhigh reasoning work, the first idle result probe waits
322
+ at least 10 minutes, idle auto-resolution waits at least 20 minutes, and the hard
323
+ failure ceiling is longer than the requested task timeout. Missing or ambiguous
324
+ gateway/session liveness fails open to "still monitoring" until the hard timeout
325
+ window or a clear terminal error.
326
+
327
+ While a label is still `running`, a plain assistant reply is diagnostic only.
328
+ Successful final delivery requires the agent-side `done` signal and its
329
+ structured completion payload. If an older watcher records an error and the
330
+ worker later sends a valid `done`, the later completion is authoritative and the
331
+ stale error is cleared from the label.
332
+
319
333
  ### Progress check-ins from subagent sessions
320
334
 
321
335
  Subagent sessions run without PATH access to the `openclaw` CLI, so
@@ -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) {