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.
- package/dispatch/completion.mjs +297 -20
- package/dispatch/index.mjs +58 -40
- package/dispatch/watcher.mjs +273 -6
- package/dispatcher-strategies.js +82 -10
- package/dispatcher.js +6 -1
- package/gateway.js +39 -0
- package/package.json +1 -1
package/dispatch/completion.mjs
CHANGED
|
@@ -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
|
|
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(
|
|
825
|
+
if (!looksTechnicalCompletionSummary(summarySource, summarized)) return stripHumanSummaryLabel(summarized) || summarized;
|
|
584
826
|
|
|
585
|
-
return buildHumanizedTechnicalSummary(
|
|
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
|
|
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(
|
|
871
|
+
parts.push(truncateText(rawTechnicalSource, 260));
|
|
621
872
|
}
|
|
622
873
|
|
|
623
874
|
let completionDetailsAreTechnical = false;
|
|
624
875
|
if (typeof details === 'string') {
|
|
625
|
-
const
|
|
626
|
-
|
|
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 !==
|
|
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
|
-
|
|
636
|
-
|
|
637
|
-
|
|
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 ||
|
|
640
|
-
parts.push(truncateText(
|
|
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
|
|
922
|
+
const summarySections = extractStructuredSummarySections(summaryText);
|
|
923
|
+
const summary = stripHumanSummaryLabel(summarySections?.summary || summaryText);
|
|
663
924
|
if (!summary) return null;
|
|
664
|
-
|
|
665
|
-
|
|
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
|
-
|
|
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) {
|
package/dispatch/index.mjs
CHANGED
|
@@ -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 {
|
|
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
|
|
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
|
|
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
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
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
|
|
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 =
|
|
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: '
|
|
713
|
-
|
|
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:
|
|
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
|
|
1092
|
-
//
|
|
1093
|
-
//
|
|
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.
|
|
1208
|
-
//
|
|
1209
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
2005
|
-
status: 'done',
|
|
2023
|
+
setLabelDone(label, {
|
|
2006
2024
|
summary,
|
|
2007
2025
|
completion,
|
|
2008
2026
|
...(sha ? { sha } : {}),
|
package/dispatch/watcher.mjs
CHANGED
|
@@ -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
|
-
|
|
820
|
-
process.stderr.write(`[watcher] [${label}] completion delivery suppressed (no meaningful reply or summary)\n`);
|
|
928
|
+
process.exit(0);
|
|
821
929
|
}
|
|
822
|
-
|
|
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
|
|
package/dispatcher-strategies.js
CHANGED
|
@@ -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
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
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
|
-
|
|
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 ${
|
|
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
|
-
|
|
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,
|
|
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
|
/**
|