brainclaw 1.5.4 → 1.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +52 -28
- package/dist/brainclaw-vscode.vsix +0 -0
- package/dist/cli.js +159 -12
- package/dist/commands/assignment-resource.js +182 -0
- package/dist/commands/bootstrap-loop.js +206 -0
- package/dist/commands/init.js +158 -22
- package/dist/commands/loop.js +156 -0
- package/dist/commands/loops-handlers.js +110 -55
- package/dist/commands/mcp-read-handlers.js +45 -4
- package/dist/commands/mcp.js +628 -205
- package/dist/commands/questions.js +180 -0
- package/dist/commands/reply.js +190 -0
- package/dist/commands/session-end.js +105 -3
- package/dist/commands/session-start.js +32 -53
- package/dist/commands/setup.js +87 -48
- package/dist/commands/switch.js +21 -1
- package/dist/core/agentrun-reconciler.js +65 -0
- package/dist/core/agentruns.js +10 -0
- package/dist/core/assignments.js +29 -10
- package/dist/core/claims.js +29 -0
- package/dist/core/context.js +1 -1
- package/dist/core/coordination.js +1 -1
- package/dist/core/dispatch-status.js +219 -0
- package/dist/core/entity-operations.js +166 -10
- package/dist/core/entity-registry.js +11 -10
- package/dist/core/execution-adapters.js +38 -2
- package/dist/core/facade-schema.js +55 -0
- package/dist/core/federation-cloud.js +27 -12
- package/dist/core/federation-materialize.js +57 -0
- package/dist/core/instruction-templates.js +2 -0
- package/dist/core/loops/bootstrap-acquire.js +195 -0
- package/dist/core/loops/facade-schema.js +68 -1
- package/dist/core/loops/hooks/bootstrap-write.js +144 -0
- package/dist/core/loops/hooks/notify-operator.js +148 -0
- package/dist/core/loops/hooks/survey-source-reader.js +256 -0
- package/dist/core/loops/index.js +8 -2
- package/dist/core/loops/next-expected.js +63 -0
- package/dist/core/loops/presets/bootstrap.js +75 -0
- package/dist/core/loops/presets/index.js +16 -0
- package/dist/core/loops/store.js +224 -4
- package/dist/core/loops/types.js +346 -1
- package/dist/core/loops/verbs.js +739 -6
- package/dist/core/schema.js +31 -2
- package/dist/core/state.js +62 -0
- package/dist/core/store-resolution.js +26 -16
- package/dist/facts.js +7 -5
- package/dist/facts.json +6 -4
- package/docs/cli.md +115 -30
- package/docs/concepts/dispatch-lifecycle.md +228 -0
- package/docs/concepts/loop-engine.md +55 -0
- package/docs/concepts/multi-agent-workflows.md +167 -166
- package/docs/concepts/troubleshooting.md +10 -2
- package/docs/integrations/agents.md +14 -14
- package/docs/integrations/codex.md +15 -12
- package/docs/integrations/mcp.md +10 -4
- package/docs/integrations/overview.md +11 -0
- package/docs/playbooks/productivity/index.md +3 -3
- package/docs/quickstart-existing-project.md +48 -28
- package/docs/quickstart.md +42 -28
- package/package.json +1 -1
package/dist/core/loops/verbs.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import crypto from 'node:crypto';
|
|
2
2
|
import { nowISO } from '../ids.js';
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
3
|
+
import { writeProjectMdSafe } from './hooks/bootstrap-write.js';
|
|
4
|
+
import { appendEvent, closeLoop, generateMutationId, getLoop, listLoopEvents, writeThreadFile, } from './store.js';
|
|
5
|
+
import { LoopArtifactSchema, PAUSE_REASONS, } from './types.js';
|
|
5
6
|
import { decideNextPhase, } from './iteration-engine.js';
|
|
6
7
|
function nextSeq(loopId, cwd) {
|
|
7
8
|
const events = listLoopEvents(loopId, cwd);
|
|
@@ -35,6 +36,8 @@ export function evaluateStopCondition(thread, condition) {
|
|
|
35
36
|
return thread.artifacts.some(isVerdictAccepted);
|
|
36
37
|
case 'max_iterations':
|
|
37
38
|
return thread.iteration_count >= condition.n;
|
|
39
|
+
case 'min_iterations':
|
|
40
|
+
return thread.iteration_count >= condition.n;
|
|
38
41
|
case 'artifact_produced':
|
|
39
42
|
return thread.artifacts.some((artifact) => artifact.phase === condition.phase && artifact.type === condition.type);
|
|
40
43
|
case 'min_artifacts_by_type': {
|
|
@@ -69,6 +72,11 @@ export function evaluateStopCondition(thread, condition) {
|
|
|
69
72
|
});
|
|
70
73
|
return matches.length >= condition.n;
|
|
71
74
|
}
|
|
75
|
+
case 'no_open_questions':
|
|
76
|
+
// pln#511 step 1 — bootstrap preset clarify-phase primitive. Mirrors
|
|
77
|
+
// the persisted `thread.open_questions` set (kept in sync by
|
|
78
|
+
// request_input / provide_input — see reconcileOpenQuestions).
|
|
79
|
+
return thread.open_questions.length === 0;
|
|
72
80
|
case 'manual':
|
|
73
81
|
return false;
|
|
74
82
|
case 'any':
|
|
@@ -124,14 +132,26 @@ function describeUnmetGate(thread, gate) {
|
|
|
124
132
|
return 'reviewer_green unmet: no accepted verdict artifact yet';
|
|
125
133
|
case 'max_iterations':
|
|
126
134
|
return `max_iterations unmet: iteration_count=${thread.iteration_count} < n=${gate.n}`;
|
|
135
|
+
case 'min_iterations':
|
|
136
|
+
return `min_iterations unmet: iteration_count (${thread.iteration_count}) < required (${gate.n})`;
|
|
127
137
|
case 'artifact_produced':
|
|
128
138
|
return `artifact_produced unmet: no artifact of type "${gate.type}" in phase "${gate.phase}"`;
|
|
139
|
+
case 'no_open_questions':
|
|
140
|
+
return `no_open_questions unmet: ${thread.open_questions.length} questions still open`;
|
|
129
141
|
case 'manual':
|
|
130
142
|
return 'manual gate: caller did not signal advance';
|
|
131
143
|
case 'any':
|
|
132
144
|
return `any-of unmet: none of ${gate.conditions.length} sub-conditions held`;
|
|
133
|
-
case 'all':
|
|
145
|
+
case 'all': {
|
|
146
|
+
// pln#516 step 2 — `all` composes other gates; the generic "at least
|
|
147
|
+
// one of N failed" tells the operator nothing actionable. Find the
|
|
148
|
+
// first sub-condition that evaluates false and report its reason so
|
|
149
|
+
// the journal records what is actually blocking the advance.
|
|
150
|
+
const failing = gate.conditions.find((c) => !evaluateStopCondition(thread, c));
|
|
151
|
+
if (failing)
|
|
152
|
+
return describeUnmetGate(thread, failing);
|
|
134
153
|
return `all-of unmet: at least one of ${gate.conditions.length} sub-conditions failed`;
|
|
154
|
+
}
|
|
135
155
|
default: {
|
|
136
156
|
const exhaustive = gate;
|
|
137
157
|
void exhaustive;
|
|
@@ -185,6 +205,20 @@ export function advance(input, cwd) {
|
|
|
185
205
|
throw new Error(`advance: phase_advance_blocked on "${current.current_phase}" — ${gateOutcome.gate_reason}`);
|
|
186
206
|
}
|
|
187
207
|
}
|
|
208
|
+
// pln#514 post-validation fix (can_27ebf1a0) — evaluate stop_condition
|
|
209
|
+
// BEFORE decideNextPhase. The iteration engine throws "already at last
|
|
210
|
+
// phase" when current_phase has no successor, which would shadow the
|
|
211
|
+
// pre-advance auto-close branch below. For a loop whose stop_condition is
|
|
212
|
+
// satisfied AT the last phase (the bootstrap preset's converge case after
|
|
213
|
+
// project_md_final lands), the right behavior is auto-close, not throw.
|
|
214
|
+
// Field-observed during pln#514 v1.1 validation (run_79f8443a).
|
|
215
|
+
if (input.to_phase === undefined && evaluateStopCondition(current, current.stop_condition)) {
|
|
216
|
+
const finalStatus = stopHitsMaxIterations(current, current.stop_condition)
|
|
217
|
+
? 'blocked'
|
|
218
|
+
: 'completed';
|
|
219
|
+
const closed = commitClosedTransition(current, finalStatus, input.actor, input.reason, cwd);
|
|
220
|
+
return { loop: closed, auto_closed: true };
|
|
221
|
+
}
|
|
188
222
|
// Decide the next state. If the caller specified a to_phase, honour it
|
|
189
223
|
// verbatim (used by `force`-style overrides and explicit jumps). Otherwise
|
|
190
224
|
// consult the iteration engine, which knows about the cycle, exit_when,
|
|
@@ -290,6 +324,23 @@ function stopHitsMaxIterations(thread, condition) {
|
|
|
290
324
|
return false;
|
|
291
325
|
}
|
|
292
326
|
function commitClosedTransition(thread, final_status, actor, reason, cwd) {
|
|
327
|
+
// pln#514 post-validation fix (can_27ebf1a0, run_79f8443a) — when the
|
|
328
|
+
// auto-close path completes a bootstrap-preset loop, delegate to
|
|
329
|
+
// closeLoop() so its writeProjectMdSafe pre-hook runs. Without this
|
|
330
|
+
// delegation, the FSM auto-close via `advance` would bypass PROJECT.md
|
|
331
|
+
// materialization entirely, defeating pln#512 step 2. The pre-hook
|
|
332
|
+
// re-reads the thread from disk; the caller (advance) has already
|
|
333
|
+
// persisted the latest state via writeThreadFile before reaching here
|
|
334
|
+
// in both the pre- and post-advance auto-close branches.
|
|
335
|
+
//
|
|
336
|
+
// Non-bootstrap loops and non-completed closes (cancelled / blocked /
|
|
337
|
+
// timeout-driven) fall through to the streamlined commit below — the
|
|
338
|
+
// pre-hook is gated on final_status='completed' && preset='bootstrap'
|
|
339
|
+
// in closeLoop, so delegating other cases would be a no-op anyway, but
|
|
340
|
+
// delegation also costs a disk re-read. Avoid it when not needed.
|
|
341
|
+
if (final_status === 'completed' && thread.protocol?.preset === 'bootstrap') {
|
|
342
|
+
return closeLoop({ id: thread.id, final_status, reason, actor }, cwd);
|
|
343
|
+
}
|
|
293
344
|
const now = nowISO();
|
|
294
345
|
const mutation_id = generateMutationId();
|
|
295
346
|
const version = thread.version + 1;
|
|
@@ -301,6 +352,10 @@ function commitClosedTransition(thread, final_status, actor, reason, cwd) {
|
|
|
301
352
|
status: final_status,
|
|
302
353
|
updated_at: now,
|
|
303
354
|
closed_at: now,
|
|
355
|
+
// Schema invariant: pause_reason / pending_file_apply only legal in
|
|
356
|
+
// status='paused'. Closing from a paused state must clear both.
|
|
357
|
+
pause_reason: undefined,
|
|
358
|
+
pending_file_apply: undefined,
|
|
304
359
|
};
|
|
305
360
|
appendEvent(thread.id, {
|
|
306
361
|
event_id: crypto.randomUUID(),
|
|
@@ -490,21 +545,474 @@ export function add_artifact(input, cwd) {
|
|
|
490
545
|
writeThreadFile(next, cwd);
|
|
491
546
|
return next;
|
|
492
547
|
}
|
|
548
|
+
/**
|
|
549
|
+
* pln#508 step 3 (Phase 0 spec §5, INVARIANT 2) — coerce a freeform `reason`
|
|
550
|
+
* string into a structured PauseReason when it matches the known enum.
|
|
551
|
+
* Returns undefined for non-matching values so legacy callers still work
|
|
552
|
+
* without populating `thread.pause_reason`.
|
|
553
|
+
*/
|
|
554
|
+
function coercePauseReason(reason) {
|
|
555
|
+
if (reason === undefined)
|
|
556
|
+
return undefined;
|
|
557
|
+
return PAUSE_REASONS.includes(reason)
|
|
558
|
+
? reason
|
|
559
|
+
: undefined;
|
|
560
|
+
}
|
|
493
561
|
export function pause(input, cwd) {
|
|
494
562
|
const current = loadLoopOrThrow(input.id, cwd);
|
|
495
563
|
if (current.status !== 'open') {
|
|
496
564
|
throw new Error(`pause: loop ${current.id} is ${current.status}, not open`);
|
|
497
565
|
}
|
|
498
|
-
|
|
566
|
+
// Resolve effective pause_reason: explicit param wins, else coerce from
|
|
567
|
+
// the freeform reason string when it matches PAUSE_REASONS, else leave
|
|
568
|
+
// undefined for backward compatibility.
|
|
569
|
+
const effectiveReason = input.pause_reason ?? coercePauseReason(input.reason);
|
|
570
|
+
return commitSimpleStatus(current, 'paused', 'paused', input.actor, input.reason, cwd, effectiveReason);
|
|
499
571
|
}
|
|
500
572
|
export function resume(input, cwd) {
|
|
501
573
|
const current = loadLoopOrThrow(input.id, cwd);
|
|
502
574
|
if (current.status !== 'paused') {
|
|
503
575
|
throw new Error(`resume: loop ${current.id} is ${current.status}, not paused`);
|
|
504
576
|
}
|
|
505
|
-
|
|
577
|
+
// Clear pause_reason on resume — schema enforces "pause_reason requires
|
|
578
|
+
// status=paused", so leaving it set would fail LoopThreadSchema validation.
|
|
579
|
+
return commitSimpleStatus(current, 'open', 'resumed', input.actor, input.reason, cwd, null);
|
|
506
580
|
}
|
|
507
|
-
|
|
581
|
+
/* ============= pln#508 step 3 — open_questions reconciliation ============== */
|
|
582
|
+
/**
|
|
583
|
+
* Phase 0 spec §5 INVARIANT 3 — compute the canonical set of open question
|
|
584
|
+
* ids from `thread.artifacts`. A question is "open" when it has an
|
|
585
|
+
* operator_question artifact and NO matching operator_answer artifact (matched
|
|
586
|
+
* by `body.replies_to === question.question_id`).
|
|
587
|
+
*
|
|
588
|
+
* The persisted `thread.open_questions` SHOULD always equal this computed set
|
|
589
|
+
* after request_input / provide_input. The debug assert in
|
|
590
|
+
* `assertOpenQuestionsInvariant` verifies the equality when
|
|
591
|
+
* `BRAINCLAW_FSM_ASSERTS=1`; in production we log a warning rather than
|
|
592
|
+
* throw so a single drift doesn't take down the engine.
|
|
593
|
+
*/
|
|
594
|
+
export function reconcileOpenQuestions(thread) {
|
|
595
|
+
const questionIds = new Set();
|
|
596
|
+
const answeredIds = new Set();
|
|
597
|
+
for (const artifact of thread.artifacts) {
|
|
598
|
+
if (!artifact.body)
|
|
599
|
+
continue;
|
|
600
|
+
if (artifact.type === 'operator_question') {
|
|
601
|
+
try {
|
|
602
|
+
const parsed = JSON.parse(artifact.body);
|
|
603
|
+
if (typeof parsed.question_id === 'string') {
|
|
604
|
+
questionIds.add(parsed.question_id);
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
catch { /* malformed body — skip; LoopArtifactSchema rejects new ones */ }
|
|
608
|
+
}
|
|
609
|
+
else if (artifact.type === 'operator_answer') {
|
|
610
|
+
try {
|
|
611
|
+
const parsed = JSON.parse(artifact.body);
|
|
612
|
+
if (typeof parsed.replies_to === 'string') {
|
|
613
|
+
answeredIds.add(parsed.replies_to);
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
catch { /* malformed body — skip */ }
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
const open = [];
|
|
620
|
+
for (const id of questionIds) {
|
|
621
|
+
if (!answeredIds.has(id))
|
|
622
|
+
open.push(id);
|
|
623
|
+
}
|
|
624
|
+
return open;
|
|
625
|
+
}
|
|
626
|
+
function fsmAssertsEnabled() {
|
|
627
|
+
return process.env.BRAINCLAW_FSM_ASSERTS === '1';
|
|
628
|
+
}
|
|
629
|
+
function assertOpenQuestionsInvariant(thread, intent) {
|
|
630
|
+
const canonical = new Set(reconcileOpenQuestions(thread));
|
|
631
|
+
const persisted = new Set(thread.open_questions);
|
|
632
|
+
if (canonical.size !== persisted.size || [...canonical].some((id) => !persisted.has(id))) {
|
|
633
|
+
const message = `[brainclaw/loops/fsm] ${intent}: open_questions drift on loop ${thread.id} ` +
|
|
634
|
+
`— persisted=${JSON.stringify([...persisted])} canonical=${JSON.stringify([...canonical])}`;
|
|
635
|
+
// Log in prod so drift is visible without taking down the engine.
|
|
636
|
+
// eslint-disable-next-line no-console
|
|
637
|
+
console.warn(message);
|
|
638
|
+
if (fsmAssertsEnabled()) {
|
|
639
|
+
throw new Error(message);
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
/**
|
|
644
|
+
* Atomic operator-question primitive (Phase 0 spec §3).
|
|
645
|
+
*
|
|
646
|
+
* Validates the question body against `OperatorQuestionBodySchema` (via
|
|
647
|
+
* `LoopArtifactSchema.parse` in `add_artifact`-style construction), enforces
|
|
648
|
+
* the protocol's `max_operator_questions` cap, appends the question to the
|
|
649
|
+
* loop's `open_questions`, and pauses either the asking slot
|
|
650
|
+
* (`pause_scope='slot'` → slot.status=waiting_input) or the whole loop
|
|
651
|
+
* (`pause_scope='loop'` → loop.status=paused, pause_reason='awaiting_operator').
|
|
652
|
+
*
|
|
653
|
+
* Refuses on terminal status or when status !== 'open' (no compounding
|
|
654
|
+
* pauses — see Phase 0 spec §5, INVARIANT 1/2).
|
|
655
|
+
*/
|
|
656
|
+
export function requestInput(input, cwd) {
|
|
657
|
+
const current = loadLoopOrThrow(input.loop_id, cwd);
|
|
658
|
+
assertMutable(current, 'request_input');
|
|
659
|
+
if (current.status !== 'open') {
|
|
660
|
+
throw new Error(`request_input: loop ${current.id} is "${current.status}", cannot accept new questions ` +
|
|
661
|
+
`(no compounding pauses — resolve current open_questions first)`);
|
|
662
|
+
}
|
|
663
|
+
if (current.open_questions.length > 0) {
|
|
664
|
+
throw new Error(`request_input: loop ${current.id} already has open_questions=${current.open_questions.length}; ` +
|
|
665
|
+
`resolve existing operator question(s) before requesting another`);
|
|
666
|
+
}
|
|
667
|
+
const max = current.protocol?.max_operator_questions;
|
|
668
|
+
if (max !== undefined) {
|
|
669
|
+
const existing = current.artifacts.filter((a) => a.type === 'operator_question').length;
|
|
670
|
+
if (existing >= max) {
|
|
671
|
+
throw new Error(`request_input: loop ${current.id} has reached max_operator_questions=${max}; ` +
|
|
672
|
+
`champion must derive remaining answers autonomously`);
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
const slot = current.slots.find((s) => s.slot_id === input.slot_id);
|
|
676
|
+
if (!slot) {
|
|
677
|
+
throw new Error(`request_input: slot ${input.slot_id} not found on loop ${current.id}`);
|
|
678
|
+
}
|
|
679
|
+
if (slot.status === 'done' || slot.status === 'failed' || slot.status === 'cancelled') {
|
|
680
|
+
throw new Error(`request_input: slot ${input.slot_id} is terminal (${slot.status})`);
|
|
681
|
+
}
|
|
682
|
+
const question_id = `qst_${crypto.randomBytes(6).toString('hex')}`;
|
|
683
|
+
const questionBody = {
|
|
684
|
+
question_id,
|
|
685
|
+
question_text: input.question_text,
|
|
686
|
+
evidence: input.evidence,
|
|
687
|
+
suggested_default: input.suggested_default,
|
|
688
|
+
options: input.options,
|
|
689
|
+
pause_scope: input.pause_scope,
|
|
690
|
+
on_timeout: input.on_timeout,
|
|
691
|
+
timeout_at: input.timeout_at,
|
|
692
|
+
by_slot_id: input.slot_id,
|
|
693
|
+
};
|
|
694
|
+
const now = nowISO();
|
|
695
|
+
const mutation_id = generateMutationId();
|
|
696
|
+
const version = current.version + 1;
|
|
697
|
+
// LoopArtifactSchema.parse runs the body schema validation for
|
|
698
|
+
// type='operator_question' via KNOWN_ARTIFACT_BODY_SCHEMAS — so any
|
|
699
|
+
// invariant violation (empty evidence, options size, on_timeout vs
|
|
700
|
+
// suggested_default) surfaces here.
|
|
701
|
+
const newArtifact = LoopArtifactSchema.parse({
|
|
702
|
+
artifact_id: `art_${crypto.randomBytes(6).toString('hex')}`,
|
|
703
|
+
phase: input.phase,
|
|
704
|
+
type: 'operator_question',
|
|
705
|
+
body: JSON.stringify(questionBody),
|
|
706
|
+
produced_by: slot.agent_id ?? slot.agent ?? input.actor,
|
|
707
|
+
produced_at: now,
|
|
708
|
+
iteration: current.iteration_count,
|
|
709
|
+
});
|
|
710
|
+
let nextStatus = current.status;
|
|
711
|
+
let nextPauseReason = current.pause_reason;
|
|
712
|
+
let nextSlots = current.slots;
|
|
713
|
+
const fromSlotStatus = slot.status;
|
|
714
|
+
if (input.pause_scope === 'loop') {
|
|
715
|
+
nextStatus = 'paused';
|
|
716
|
+
nextPauseReason = 'awaiting_operator';
|
|
717
|
+
}
|
|
718
|
+
else {
|
|
719
|
+
nextSlots = current.slots.map((s) => s.slot_id === input.slot_id ? { ...s, status: 'waiting_input' } : s);
|
|
720
|
+
}
|
|
721
|
+
const next = {
|
|
722
|
+
...current,
|
|
723
|
+
version,
|
|
724
|
+
mutation_id,
|
|
725
|
+
artifacts: [...current.artifacts, newArtifact],
|
|
726
|
+
open_questions: [...current.open_questions, question_id],
|
|
727
|
+
status: nextStatus,
|
|
728
|
+
pause_reason: nextPauseReason,
|
|
729
|
+
slots: nextSlots,
|
|
730
|
+
updated_at: now,
|
|
731
|
+
};
|
|
732
|
+
let seq = nextSeq(current.id, cwd);
|
|
733
|
+
appendEvent(current.id, {
|
|
734
|
+
event_id: crypto.randomUUID(),
|
|
735
|
+
loop_id: current.id,
|
|
736
|
+
seq,
|
|
737
|
+
at: now,
|
|
738
|
+
by: input.actor,
|
|
739
|
+
mutation_id,
|
|
740
|
+
kind: 'input_requested',
|
|
741
|
+
question_id,
|
|
742
|
+
pause_scope: input.pause_scope,
|
|
743
|
+
by_slot_id: input.slot_id,
|
|
744
|
+
}, cwd,
|
|
745
|
+
// pln#513 phase 4 codex review fix — pass the next thread so the
|
|
746
|
+
// notification hook sees the freshly-added operator_question rather
|
|
747
|
+
// than re-reading the previous on-disk snapshot.
|
|
748
|
+
next);
|
|
749
|
+
if (input.pause_scope === 'slot') {
|
|
750
|
+
seq += 1;
|
|
751
|
+
appendEvent(current.id, {
|
|
752
|
+
event_id: crypto.randomUUID(),
|
|
753
|
+
loop_id: current.id,
|
|
754
|
+
seq,
|
|
755
|
+
at: now,
|
|
756
|
+
by: input.actor,
|
|
757
|
+
mutation_id,
|
|
758
|
+
kind: 'slot_status_changed',
|
|
759
|
+
slot_id: input.slot_id,
|
|
760
|
+
from_status: fromSlotStatus,
|
|
761
|
+
to_status: 'waiting_input',
|
|
762
|
+
}, cwd);
|
|
763
|
+
}
|
|
764
|
+
writeThreadFile(next, cwd);
|
|
765
|
+
assertOpenQuestionsInvariant(next, 'request_input');
|
|
766
|
+
return { thread: next, question_id, artifact_id: newArtifact.artifact_id };
|
|
767
|
+
}
|
|
768
|
+
/**
|
|
769
|
+
* Resolves an open operator_question with an operator (or synthetic
|
|
770
|
+
* timeout-default) answer. Phase 0 spec §3 atomic operation:
|
|
771
|
+
* 1. Resolve `replies_to`:
|
|
772
|
+
* - In `open_questions` → proceed.
|
|
773
|
+
* - Else, find existing operator_answer with same `replies_to` →
|
|
774
|
+
* return the existing artifact (idempotent replay).
|
|
775
|
+
* - Else → throw `unknown_question`.
|
|
776
|
+
* 2. Materialize `answer_text` / `chosen_option_id` from the source
|
|
777
|
+
* question's `suggested_default` when `resolved_via='skip'` or
|
|
778
|
+
* `'timeout_default'` and the caller didn't pass them.
|
|
779
|
+
* 3. Append the operator_answer artifact (validated body).
|
|
780
|
+
* 4. Remove the question_id from `open_questions`.
|
|
781
|
+
* 5. Resume: if source had `pause_scope='slot'`, transition the asking
|
|
782
|
+
* slot back to 'working'. If `pause_scope='loop'` AND open_questions
|
|
783
|
+
* now empty AND pause_reason='awaiting_operator', resume the loop.
|
|
784
|
+
* 6. Emit `input_provided` (+ `slot_status_changed` for slot scope).
|
|
785
|
+
*/
|
|
786
|
+
export function provideInput(input, cwd) {
|
|
787
|
+
const current = loadLoopOrThrow(input.loop_id, cwd);
|
|
788
|
+
// assertMutable allows paused loops (we need to resume them); only
|
|
789
|
+
// refuse terminal status.
|
|
790
|
+
if (current.status === 'completed' || current.status === 'cancelled' || current.status === 'blocked') {
|
|
791
|
+
throw new Error(`provide_input: loop ${current.id} is already ${current.status}`);
|
|
792
|
+
}
|
|
793
|
+
const isOpen = current.open_questions.includes(input.replies_to);
|
|
794
|
+
if (!isOpen) {
|
|
795
|
+
// Idempotent-replay path: look for an existing operator_answer with
|
|
796
|
+
// matching replies_to in the artifact list.
|
|
797
|
+
const existing = current.artifacts.find((a) => {
|
|
798
|
+
if (a.type !== 'operator_answer' || !a.body)
|
|
799
|
+
return false;
|
|
800
|
+
try {
|
|
801
|
+
const parsed = JSON.parse(a.body);
|
|
802
|
+
return parsed.replies_to === input.replies_to;
|
|
803
|
+
}
|
|
804
|
+
catch {
|
|
805
|
+
return false;
|
|
806
|
+
}
|
|
807
|
+
});
|
|
808
|
+
if (existing) {
|
|
809
|
+
return { thread: current, artifact_id: existing.artifact_id, duplicate: true };
|
|
810
|
+
}
|
|
811
|
+
throw new Error(`provide_input: unknown_question — replies_to "${input.replies_to}" is not in ` +
|
|
812
|
+
`open_questions and no existing operator_answer artifact references it`);
|
|
813
|
+
}
|
|
814
|
+
// Locate the source question to determine pause_scope and by_slot_id.
|
|
815
|
+
const sourceQuestion = current.artifacts.find((a) => {
|
|
816
|
+
if (a.type !== 'operator_question' || !a.body)
|
|
817
|
+
return false;
|
|
818
|
+
try {
|
|
819
|
+
const parsed = JSON.parse(a.body);
|
|
820
|
+
return parsed.question_id === input.replies_to;
|
|
821
|
+
}
|
|
822
|
+
catch {
|
|
823
|
+
return false;
|
|
824
|
+
}
|
|
825
|
+
});
|
|
826
|
+
if (!sourceQuestion || !sourceQuestion.body) {
|
|
827
|
+
throw new Error(`provide_input: question ${input.replies_to} is in open_questions but its artifact ` +
|
|
828
|
+
`was not found on the loop — state corruption`);
|
|
829
|
+
}
|
|
830
|
+
const sourceBody = JSON.parse(sourceQuestion.body);
|
|
831
|
+
// Materialize default values for skip / timeout_default resolutions.
|
|
832
|
+
let answerText = input.answer_text;
|
|
833
|
+
let chosenOptionId = input.chosen_option_id;
|
|
834
|
+
if ((input.resolved_via === 'skip' || input.resolved_via === 'timeout_default') &&
|
|
835
|
+
answerText === undefined &&
|
|
836
|
+
chosenOptionId === undefined) {
|
|
837
|
+
if (sourceBody.suggested_default === undefined) {
|
|
838
|
+
throw new Error(`provide_input: resolved_via="${input.resolved_via}" without an explicit ` +
|
|
839
|
+
`answer requires the source question to have suggested_default set`);
|
|
840
|
+
}
|
|
841
|
+
if (sourceBody.options) {
|
|
842
|
+
chosenOptionId = sourceBody.suggested_default;
|
|
843
|
+
}
|
|
844
|
+
else {
|
|
845
|
+
answerText = sourceBody.suggested_default;
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
const by = input.by ?? 'operator';
|
|
849
|
+
const synthetic = by === 'system';
|
|
850
|
+
const answerBody = {
|
|
851
|
+
replies_to: input.replies_to,
|
|
852
|
+
resolved_via: input.resolved_via,
|
|
853
|
+
answer_text: answerText,
|
|
854
|
+
chosen_option_id: chosenOptionId,
|
|
855
|
+
by,
|
|
856
|
+
synthetic: synthetic ? true : undefined,
|
|
857
|
+
};
|
|
858
|
+
const now = nowISO();
|
|
859
|
+
const mutation_id = generateMutationId();
|
|
860
|
+
const version = current.version + 1;
|
|
861
|
+
const newArtifact = LoopArtifactSchema.parse({
|
|
862
|
+
artifact_id: `art_${crypto.randomBytes(6).toString('hex')}`,
|
|
863
|
+
phase: sourceQuestion.phase,
|
|
864
|
+
type: 'operator_answer',
|
|
865
|
+
body: JSON.stringify(answerBody),
|
|
866
|
+
produced_by: by === 'system' ? 'engine' : input.actor,
|
|
867
|
+
produced_at: now,
|
|
868
|
+
iteration: current.iteration_count,
|
|
869
|
+
});
|
|
870
|
+
const nextOpenQuestions = current.open_questions.filter((q) => q !== input.replies_to);
|
|
871
|
+
// Widen to LoopStatus — the assertMutable check above narrowed current.status
|
|
872
|
+
// to 'open' | 'paused', but the file_apply post-hook below can transition
|
|
873
|
+
// directly to 'completed' (pln#512 step 2).
|
|
874
|
+
let nextStatus = current.status;
|
|
875
|
+
let nextPauseReason = current.pause_reason;
|
|
876
|
+
let nextSlots = current.slots;
|
|
877
|
+
let nextPendingFileApply = current.pending_file_apply;
|
|
878
|
+
let nextClosedAt = current.closed_at;
|
|
879
|
+
let resumedSlotId;
|
|
880
|
+
let resumedSlotFromStatus;
|
|
881
|
+
// pln#512 step 2 — file_overwrite_approval resolution path. When the loop
|
|
882
|
+
// is paused on a file_apply question (set by the closeLoop bootstrap
|
|
883
|
+
// pre-hook), the answer carries the operator's approve/reject decision.
|
|
884
|
+
// Approve → write PROJECT.md atomically; reject → leave target untouched.
|
|
885
|
+
// Either way, the loop transitions directly to status='completed' so the
|
|
886
|
+
// caller's original `closeLoop(final_status='completed')` intent is
|
|
887
|
+
// honoured without requiring a second close round-trip.
|
|
888
|
+
let fileApplyResolution;
|
|
889
|
+
if (sourceBody.pause_scope === 'loop' &&
|
|
890
|
+
nextOpenQuestions.length === 0 &&
|
|
891
|
+
current.pause_reason === 'awaiting_file_apply' &&
|
|
892
|
+
current.pending_file_apply !== undefined) {
|
|
893
|
+
const approved = chosenOptionId === 'approve';
|
|
894
|
+
if (approved) {
|
|
895
|
+
// Idempotent on the source artifact: writeProjectMdSafe re-reads
|
|
896
|
+
// project_md_final and atomic-writes the target. Failure here propagates
|
|
897
|
+
// — we don't want to mark the loop completed if the write failed.
|
|
898
|
+
writeProjectMdSafe(current, cwd, { approved: true });
|
|
899
|
+
}
|
|
900
|
+
fileApplyResolution = {
|
|
901
|
+
approved,
|
|
902
|
+
artifact_id: current.pending_file_apply.artifact_id,
|
|
903
|
+
};
|
|
904
|
+
nextStatus = 'completed';
|
|
905
|
+
nextPauseReason = undefined;
|
|
906
|
+
nextPendingFileApply = undefined;
|
|
907
|
+
nextClosedAt = now;
|
|
908
|
+
}
|
|
909
|
+
else if (sourceBody.pause_scope === 'slot') {
|
|
910
|
+
const slotId = sourceBody.by_slot_id;
|
|
911
|
+
if (slotId) {
|
|
912
|
+
const slot = current.slots.find((s) => s.slot_id === slotId);
|
|
913
|
+
if (slot && slot.status === 'waiting_input') {
|
|
914
|
+
resumedSlotId = slotId;
|
|
915
|
+
resumedSlotFromStatus = slot.status;
|
|
916
|
+
nextSlots = current.slots.map((s) => s.slot_id === slotId ? { ...s, status: 'working' } : s);
|
|
917
|
+
}
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
else if (sourceBody.pause_scope === 'loop' &&
|
|
921
|
+
nextOpenQuestions.length === 0 &&
|
|
922
|
+
current.pause_reason === 'awaiting_operator') {
|
|
923
|
+
nextStatus = 'open';
|
|
924
|
+
nextPauseReason = undefined;
|
|
925
|
+
}
|
|
926
|
+
const next = {
|
|
927
|
+
...current,
|
|
928
|
+
version,
|
|
929
|
+
mutation_id,
|
|
930
|
+
artifacts: [...current.artifacts, newArtifact],
|
|
931
|
+
open_questions: nextOpenQuestions,
|
|
932
|
+
status: nextStatus,
|
|
933
|
+
pause_reason: nextPauseReason,
|
|
934
|
+
pending_file_apply: nextPendingFileApply,
|
|
935
|
+
closed_at: nextClosedAt,
|
|
936
|
+
slots: nextSlots,
|
|
937
|
+
updated_at: now,
|
|
938
|
+
};
|
|
939
|
+
let seq = nextSeq(current.id, cwd);
|
|
940
|
+
appendEvent(current.id, {
|
|
941
|
+
event_id: crypto.randomUUID(),
|
|
942
|
+
loop_id: current.id,
|
|
943
|
+
seq,
|
|
944
|
+
at: now,
|
|
945
|
+
by: input.actor,
|
|
946
|
+
mutation_id,
|
|
947
|
+
kind: 'input_provided',
|
|
948
|
+
question_id: input.replies_to,
|
|
949
|
+
resolved_via: input.resolved_via,
|
|
950
|
+
answered_by: by,
|
|
951
|
+
synthetic,
|
|
952
|
+
}, cwd);
|
|
953
|
+
if (resumedSlotId && resumedSlotFromStatus) {
|
|
954
|
+
seq += 1;
|
|
955
|
+
appendEvent(current.id, {
|
|
956
|
+
event_id: crypto.randomUUID(),
|
|
957
|
+
loop_id: current.id,
|
|
958
|
+
seq,
|
|
959
|
+
at: now,
|
|
960
|
+
by: input.actor,
|
|
961
|
+
mutation_id,
|
|
962
|
+
kind: 'slot_status_changed',
|
|
963
|
+
slot_id: resumedSlotId,
|
|
964
|
+
from_status: resumedSlotFromStatus,
|
|
965
|
+
to_status: 'working',
|
|
966
|
+
}, cwd);
|
|
967
|
+
}
|
|
968
|
+
// pln#512 step 2 — file_apply close-out events. The file_apply_resolved
|
|
969
|
+
// event must precede the closed event so the journal reads in causal
|
|
970
|
+
// order (resolve → close), mirroring the absent/empty branch in
|
|
971
|
+
// closeLoop.
|
|
972
|
+
if (fileApplyResolution !== undefined) {
|
|
973
|
+
seq += 1;
|
|
974
|
+
appendEvent(current.id, {
|
|
975
|
+
event_id: crypto.randomUUID(),
|
|
976
|
+
loop_id: current.id,
|
|
977
|
+
seq,
|
|
978
|
+
at: now,
|
|
979
|
+
by: input.actor,
|
|
980
|
+
mutation_id,
|
|
981
|
+
kind: 'file_apply_resolved',
|
|
982
|
+
artifact_id: fileApplyResolution.artifact_id,
|
|
983
|
+
approved: fileApplyResolution.approved,
|
|
984
|
+
}, cwd);
|
|
985
|
+
seq += 1;
|
|
986
|
+
appendEvent(current.id, {
|
|
987
|
+
event_id: crypto.randomUUID(),
|
|
988
|
+
loop_id: current.id,
|
|
989
|
+
seq,
|
|
990
|
+
at: now,
|
|
991
|
+
by: input.actor,
|
|
992
|
+
mutation_id,
|
|
993
|
+
kind: 'closed',
|
|
994
|
+
final_status: 'completed',
|
|
995
|
+
reason: fileApplyResolution.approved
|
|
996
|
+
? 'file_overwrite_approved'
|
|
997
|
+
: 'file_overwrite_rejected',
|
|
998
|
+
}, cwd);
|
|
999
|
+
}
|
|
1000
|
+
writeThreadFile(next, cwd);
|
|
1001
|
+
assertOpenQuestionsInvariant(next, 'provide_input');
|
|
1002
|
+
return { thread: next, artifact_id: newArtifact.artifact_id, duplicate: false };
|
|
1003
|
+
}
|
|
1004
|
+
/* =========================== /pln#508 step 2 ============================== */
|
|
1005
|
+
/**
|
|
1006
|
+
* Commit a simple open ↔ paused transition.
|
|
1007
|
+
*
|
|
1008
|
+
* `pauseReasonOverride` semantics (pln#508 step 3):
|
|
1009
|
+
* - undefined → leave `thread.pause_reason` as-is (pass-through via spread).
|
|
1010
|
+
* - PauseReason value → write that value to `thread.pause_reason`.
|
|
1011
|
+
* - null → explicitly clear `thread.pause_reason` (used by resume() to
|
|
1012
|
+
* satisfy the LoopThreadSchema invariant "pause_reason requires
|
|
1013
|
+
* status=paused").
|
|
1014
|
+
*/
|
|
1015
|
+
function commitSimpleStatus(current, newStatus, eventKind, actor, reason, cwd, pauseReasonOverride) {
|
|
508
1016
|
const now = nowISO();
|
|
509
1017
|
const mutation_id = generateMutationId();
|
|
510
1018
|
const version = current.version + 1;
|
|
@@ -515,6 +1023,9 @@ function commitSimpleStatus(current, newStatus, eventKind, actor, reason, cwd) {
|
|
|
515
1023
|
mutation_id,
|
|
516
1024
|
status: newStatus,
|
|
517
1025
|
updated_at: now,
|
|
1026
|
+
pause_reason: pauseReasonOverride === null
|
|
1027
|
+
? undefined
|
|
1028
|
+
: pauseReasonOverride ?? current.pause_reason,
|
|
518
1029
|
};
|
|
519
1030
|
const base = {
|
|
520
1031
|
event_id: crypto.randomUUID(),
|
|
@@ -531,4 +1042,226 @@ function commitSimpleStatus(current, newStatus, eventKind, actor, reason, cwd) {
|
|
|
531
1042
|
writeThreadFile(next, cwd);
|
|
532
1043
|
return next;
|
|
533
1044
|
}
|
|
1045
|
+
export function sweepPauseTimeouts(loop_id, now, cwd) {
|
|
1046
|
+
const nowMs = (now ?? new Date()).getTime();
|
|
1047
|
+
let current = getLoop(loop_id, cwd);
|
|
1048
|
+
if (!current) {
|
|
1049
|
+
return { loop_id, fired: [] };
|
|
1050
|
+
}
|
|
1051
|
+
// Terminal loops never get swept (Phase 0 spec §6).
|
|
1052
|
+
if (current.status === 'completed' ||
|
|
1053
|
+
current.status === 'cancelled' ||
|
|
1054
|
+
current.status === 'blocked') {
|
|
1055
|
+
return { loop_id, fired: [] };
|
|
1056
|
+
}
|
|
1057
|
+
if (current.open_questions.length === 0) {
|
|
1058
|
+
return { loop_id, fired: [] };
|
|
1059
|
+
}
|
|
1060
|
+
const fired = [];
|
|
1061
|
+
// Snapshot question_ids: we mutate state inside the loop and want a stable
|
|
1062
|
+
// iteration set even if e.g. provideInput's cascade clears multiple.
|
|
1063
|
+
const candidates = [...current.open_questions];
|
|
1064
|
+
for (const question_id of candidates) {
|
|
1065
|
+
// Reload between iterations — provideInput / continue_incomplete /
|
|
1066
|
+
// commitClosedTransition all rewrite the thread, and a previous iteration
|
|
1067
|
+
// may have already taken the loop terminal.
|
|
1068
|
+
const reloaded = getLoop(loop_id, cwd);
|
|
1069
|
+
if (!reloaded)
|
|
1070
|
+
break;
|
|
1071
|
+
current = reloaded;
|
|
1072
|
+
if (current.status === 'completed' ||
|
|
1073
|
+
current.status === 'cancelled' ||
|
|
1074
|
+
current.status === 'blocked') {
|
|
1075
|
+
break;
|
|
1076
|
+
}
|
|
1077
|
+
if (!current.open_questions.includes(question_id))
|
|
1078
|
+
continue;
|
|
1079
|
+
const source = findOperatorQuestion(current, question_id);
|
|
1080
|
+
if (!source)
|
|
1081
|
+
continue;
|
|
1082
|
+
const deadlineMs = resolveDeadlineMs(current, source);
|
|
1083
|
+
if (deadlineMs === undefined)
|
|
1084
|
+
continue;
|
|
1085
|
+
if (nowMs <= deadlineMs)
|
|
1086
|
+
continue;
|
|
1087
|
+
const policy = source.body.on_timeout;
|
|
1088
|
+
if (policy === 'use_default') {
|
|
1089
|
+
try {
|
|
1090
|
+
provideInput({
|
|
1091
|
+
loop_id,
|
|
1092
|
+
replies_to: question_id,
|
|
1093
|
+
resolved_via: 'timeout_default',
|
|
1094
|
+
by: 'system',
|
|
1095
|
+
actor: 'engine',
|
|
1096
|
+
}, cwd);
|
|
1097
|
+
}
|
|
1098
|
+
catch {
|
|
1099
|
+
// If provideInput rejects (e.g. missing suggested_default that should
|
|
1100
|
+
// have been caught at request_input time), fall through so we still
|
|
1101
|
+
// record an audit event marking the attempted sweep.
|
|
1102
|
+
continue;
|
|
1103
|
+
}
|
|
1104
|
+
emitPauseTimeoutEvent(loop_id, question_id, 'use_default', cwd);
|
|
1105
|
+
fired.push({ question_id, action_taken: 'use_default' });
|
|
1106
|
+
}
|
|
1107
|
+
else if (policy === 'cancel_loop') {
|
|
1108
|
+
// Re-read after we know we're firing — cancel transitions the loop
|
|
1109
|
+
// to terminal and persists.
|
|
1110
|
+
const beforeCancel = getLoop(loop_id, cwd);
|
|
1111
|
+
if (!beforeCancel)
|
|
1112
|
+
break;
|
|
1113
|
+
commitClosedTransition(beforeCancel, 'cancelled', 'engine', 'operator_timeout', cwd);
|
|
1114
|
+
emitPauseTimeoutEvent(loop_id, question_id, 'cancel_loop', cwd);
|
|
1115
|
+
fired.push({ question_id, action_taken: 'cancel_loop' });
|
|
1116
|
+
// Subsequent candidates can't fire on a terminal loop.
|
|
1117
|
+
break;
|
|
1118
|
+
}
|
|
1119
|
+
else if (policy === 'continue_incomplete') {
|
|
1120
|
+
applyContinueIncomplete(current, question_id, source.body, cwd);
|
|
1121
|
+
emitPauseTimeoutEvent(loop_id, question_id, 'continue_incomplete', cwd);
|
|
1122
|
+
fired.push({ question_id, action_taken: 'continue_incomplete' });
|
|
1123
|
+
}
|
|
1124
|
+
}
|
|
1125
|
+
return { loop_id, fired };
|
|
1126
|
+
}
|
|
1127
|
+
function emitPauseTimeoutEvent(loop_id, question_id, action_taken, cwd) {
|
|
1128
|
+
appendEvent(loop_id, {
|
|
1129
|
+
event_id: crypto.randomUUID(),
|
|
1130
|
+
loop_id,
|
|
1131
|
+
seq: nextSeq(loop_id, cwd),
|
|
1132
|
+
at: nowISO(),
|
|
1133
|
+
by: 'engine',
|
|
1134
|
+
mutation_id: generateMutationId(),
|
|
1135
|
+
kind: 'pause_timeout',
|
|
1136
|
+
question_id,
|
|
1137
|
+
action_taken,
|
|
1138
|
+
}, cwd);
|
|
1139
|
+
}
|
|
1140
|
+
function findOperatorQuestion(thread, question_id) {
|
|
1141
|
+
for (const artifact of thread.artifacts) {
|
|
1142
|
+
if (artifact.type !== 'operator_question' || !artifact.body)
|
|
1143
|
+
continue;
|
|
1144
|
+
try {
|
|
1145
|
+
const body = JSON.parse(artifact.body);
|
|
1146
|
+
if (body.question_id === question_id) {
|
|
1147
|
+
return { artifact, body };
|
|
1148
|
+
}
|
|
1149
|
+
}
|
|
1150
|
+
catch { /* skip malformed */ }
|
|
1151
|
+
}
|
|
1152
|
+
return undefined;
|
|
1153
|
+
}
|
|
1154
|
+
/**
|
|
1155
|
+
* Compute the deadline (ms since epoch) for a question. Prefers the
|
|
1156
|
+
* artifact's explicit `timeout_at`. Falls back to `produced_at +
|
|
1157
|
+
* protocol.max_pause_duration` (ISO-8601 duration). Returns undefined when
|
|
1158
|
+
* neither source yields a usable value.
|
|
1159
|
+
*/
|
|
1160
|
+
function resolveDeadlineMs(thread, source) {
|
|
1161
|
+
if (source.body.timeout_at) {
|
|
1162
|
+
const parsed = Date.parse(source.body.timeout_at);
|
|
1163
|
+
if (!Number.isNaN(parsed))
|
|
1164
|
+
return parsed;
|
|
1165
|
+
}
|
|
1166
|
+
const max = thread.protocol?.max_pause_duration;
|
|
1167
|
+
if (!max)
|
|
1168
|
+
return undefined;
|
|
1169
|
+
const startMs = Date.parse(source.artifact.produced_at);
|
|
1170
|
+
if (Number.isNaN(startMs))
|
|
1171
|
+
return undefined;
|
|
1172
|
+
const durationMs = parseIso8601DurationMs(max);
|
|
1173
|
+
if (durationMs === undefined)
|
|
1174
|
+
return undefined;
|
|
1175
|
+
return startMs + durationMs;
|
|
1176
|
+
}
|
|
1177
|
+
/**
|
|
1178
|
+
* Minimal ISO-8601 duration parser. Supports the subset needed by the
|
|
1179
|
+
* bootstrap preset: P[n]D[ T[n]H[n]M[n]S ]. Returns the total duration in
|
|
1180
|
+
* milliseconds, or undefined when the input is not a recognised duration.
|
|
1181
|
+
* Note: months/years are intentionally NOT supported — they have no
|
|
1182
|
+
* fixed millisecond length and the preset only needs days/hours/minutes.
|
|
1183
|
+
*
|
|
1184
|
+
* Bound (can_45f6a7fb): output is rejected when ms is non-finite, ≤0,
|
|
1185
|
+
* or exceeds MAX_PAUSE_DURATION_MS (365 days). Without this bound, inputs
|
|
1186
|
+
* like `P99999999999999D` parse to `Infinity` or unsafe integers, and
|
|
1187
|
+
* `now > timeout_at` evaluates falsy ⇒ the deadline never fires ⇒
|
|
1188
|
+
* paused loops hang forever with no recovery path.
|
|
1189
|
+
*/
|
|
1190
|
+
const MAX_PAUSE_DURATION_MS = 365 * 24 * 60 * 60 * 1000;
|
|
1191
|
+
function parseIso8601DurationMs(input) {
|
|
1192
|
+
const match = /^P(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+(?:\.\d+)?)S)?)?$/.exec(input);
|
|
1193
|
+
if (!match)
|
|
1194
|
+
return undefined;
|
|
1195
|
+
const [, daysStr, hoursStr, minutesStr, secondsStr] = match;
|
|
1196
|
+
// If everything is missing the regex still matches "P" — guard against
|
|
1197
|
+
// returning 0 for a meaningless string.
|
|
1198
|
+
if (!daysStr && !hoursStr && !minutesStr && !secondsStr)
|
|
1199
|
+
return undefined;
|
|
1200
|
+
const days = daysStr ? Number(daysStr) : 0;
|
|
1201
|
+
const hours = hoursStr ? Number(hoursStr) : 0;
|
|
1202
|
+
const minutes = minutesStr ? Number(minutesStr) : 0;
|
|
1203
|
+
const seconds = secondsStr ? Number(secondsStr) : 0;
|
|
1204
|
+
const ms = (((days * 24 + hours) * 60 + minutes) * 60 + seconds) * 1000;
|
|
1205
|
+
if (!Number.isFinite(ms) || ms <= 0 || ms > MAX_PAUSE_DURATION_MS) {
|
|
1206
|
+
return undefined;
|
|
1207
|
+
}
|
|
1208
|
+
return ms;
|
|
1209
|
+
}
|
|
1210
|
+
/**
|
|
1211
|
+
* Apply the `continue_incomplete` timeout policy: drop the question_id from
|
|
1212
|
+
* `open_questions` without creating an answer artifact, and resume the slot
|
|
1213
|
+
* or loop per the source `pause_scope`. Atomic: one version bump, no
|
|
1214
|
+
* `input_provided` event (continue_incomplete is explicitly "no answer").
|
|
1215
|
+
*/
|
|
1216
|
+
function applyContinueIncomplete(current, question_id, body, cwd) {
|
|
1217
|
+
const now = nowISO();
|
|
1218
|
+
const mutation_id = generateMutationId();
|
|
1219
|
+
const version = current.version + 1;
|
|
1220
|
+
const nextOpenQuestions = current.open_questions.filter((q) => q !== question_id);
|
|
1221
|
+
let nextStatus = current.status;
|
|
1222
|
+
let nextPauseReason = current.pause_reason;
|
|
1223
|
+
let nextSlots = current.slots;
|
|
1224
|
+
let resumedSlotId;
|
|
1225
|
+
let resumedSlotFromStatus;
|
|
1226
|
+
if (body.pause_scope === 'slot' && body.by_slot_id) {
|
|
1227
|
+
const slot = current.slots.find((s) => s.slot_id === body.by_slot_id);
|
|
1228
|
+
if (slot && slot.status === 'waiting_input') {
|
|
1229
|
+
resumedSlotId = slot.slot_id;
|
|
1230
|
+
resumedSlotFromStatus = slot.status;
|
|
1231
|
+
nextSlots = current.slots.map((s) => s.slot_id === slot.slot_id ? { ...s, status: 'working' } : s);
|
|
1232
|
+
}
|
|
1233
|
+
}
|
|
1234
|
+
else if (body.pause_scope === 'loop' &&
|
|
1235
|
+
nextOpenQuestions.length === 0 &&
|
|
1236
|
+
current.pause_reason === 'awaiting_operator') {
|
|
1237
|
+
nextStatus = 'open';
|
|
1238
|
+
nextPauseReason = undefined;
|
|
1239
|
+
}
|
|
1240
|
+
const next = {
|
|
1241
|
+
...current,
|
|
1242
|
+
version,
|
|
1243
|
+
mutation_id,
|
|
1244
|
+
open_questions: nextOpenQuestions,
|
|
1245
|
+
status: nextStatus,
|
|
1246
|
+
pause_reason: nextPauseReason,
|
|
1247
|
+
slots: nextSlots,
|
|
1248
|
+
updated_at: now,
|
|
1249
|
+
};
|
|
1250
|
+
if (resumedSlotId && resumedSlotFromStatus) {
|
|
1251
|
+
appendEvent(current.id, {
|
|
1252
|
+
event_id: crypto.randomUUID(),
|
|
1253
|
+
loop_id: current.id,
|
|
1254
|
+
seq: nextSeq(current.id, cwd),
|
|
1255
|
+
at: now,
|
|
1256
|
+
by: 'engine',
|
|
1257
|
+
mutation_id,
|
|
1258
|
+
kind: 'slot_status_changed',
|
|
1259
|
+
slot_id: resumedSlotId,
|
|
1260
|
+
from_status: resumedSlotFromStatus,
|
|
1261
|
+
to_status: 'working',
|
|
1262
|
+
}, cwd);
|
|
1263
|
+
}
|
|
1264
|
+
writeThreadFile(next, cwd);
|
|
1265
|
+
return next;
|
|
1266
|
+
}
|
|
534
1267
|
//# sourceMappingURL=verbs.js.map
|