muse-crew 0.14.11 → 0.14.13
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/docs/decisions/AGENTS.md
CHANGED
|
@@ -6,6 +6,12 @@ history lives here. One canonical section per `<a id>` anchor — a critic
|
|
|
6
6
|
re-verified that every `docs/decisions/*.md#anchor` reference in
|
|
7
7
|
`workflows/*.js` resolves (45 unique references).
|
|
8
8
|
|
|
9
|
+
## dispatch-decision-log.md — Decision history: dispatch decision log (hoverboat)
|
|
10
|
+
|
|
11
|
+
- `#checksum-vs-hoverboat` — The ferry-corruption problem and the two candidates
|
|
12
|
+
- `#why-hoverboat-won` — Availability, trust, subtraction, primary-source rationale
|
|
13
|
+
- `#what-was-built` — Declaration shape, skip counters, observer ingestion, retirements
|
|
14
|
+
|
|
9
15
|
## publish-path.md — Decision history: publish path
|
|
10
16
|
|
|
11
17
|
- `#fire-and-forget-trigger` — Fire-and-forget trigger + workflow-owned observation
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# Dispatch decision log (hoverboat) — 2026-09-24
|
|
2
|
+
|
|
3
|
+
<a id="checksum-vs-hoverboat"></a>
|
|
4
|
+
## The problem
|
|
5
|
+
|
|
6
|
+
The Gate 2 observer attributed dispatch decisions by ferrying platform
|
|
7
|
+
`scheduler.job_runs` rows through an LLM paste (tick worker: `muse.db`
|
|
8
|
+
result → heredoc → `record_platform.sh --dispatch` → `dispatch_sample`
|
|
9
|
+
rows → `attribute_dispatch.sh` → `dispatch_decision` rows). The paste
|
|
10
|
+
mutated plausible values: a 2026-09-24 consistency scan found 275
|
|
11
|
+
same-`run_id` contradictory `dispatch_sample` rows and 65 derived
|
|
12
|
+
`dispatch_decision` rows based on corrupt data (5.7% of samples
|
|
13
|
+
quarantined; 63.6% of ticks touched). The older `quarantine_suspects.sh`
|
|
14
|
+
handled near-neighbor UUID mutations, not plausible value mutation.
|
|
15
|
+
|
|
16
|
+
## The two candidates
|
|
17
|
+
|
|
18
|
+
**Checksum ferry** (containment): keep the ferry; the worker checksums the
|
|
19
|
+
raw tool-result JSON before the paste and `record_platform.sh` verifies
|
|
20
|
+
after. Corrupt pastes become loud absences instead of silent corruption.
|
|
21
|
+
|
|
22
|
+
**Hoverboat** (local declaration): the dispatcher writes its decision to
|
|
23
|
+
`$CREW_HOME/.dispatch-decisions.jsonl` in its own bytes (deterministic JS,
|
|
24
|
+
same courier shape as the proven §4b tick-release writer); the observer
|
|
25
|
+
reads the file directly and verifies launch declarations mechanically
|
|
26
|
+
against crew DB effects (`platform_run_tasks`, `dispatch_reservations`).
|
|
27
|
+
|
|
28
|
+
<a id="why-hoverboat-won"></a>
|
|
29
|
+
## Why hoverboat won
|
|
30
|
+
|
|
31
|
+
1. **Availability.** Corruption touched ~63.6% of ticks. Checksum converts
|
|
32
|
+
corruption into absence, but the entry demo needs ten consecutive ticks
|
|
33
|
+
each recording a decision — absence fails the demo as surely as
|
|
34
|
+
corruption. Hoverboat's channel has no LLM re-emission at all.
|
|
35
|
+
2. **Trust.** The checksum would be computed by the same worker agent that
|
|
36
|
+
corrupts the paste — asking the corrupting agent to honestly report its
|
|
37
|
+
own corruption. Hoverboat's declaration is composed by deterministic
|
|
38
|
+
workflow JS and checked against independent DB effects.
|
|
39
|
+
3. **Subtraction.** Checksum keeps the ferry, the rolling 25h re-record
|
|
40
|
+
window, and the quarantine machinery permanently busy. Hoverboat deletes
|
|
41
|
+
the sample→decision attribution entirely; the platform ferry remains
|
|
42
|
+
only as platform-health telemetry.
|
|
43
|
+
4. **The crew already knows.** Having the platform tell the observer what
|
|
44
|
+
the crew decided, through an LLM paste, when the crew can write it
|
|
45
|
+
directly, is the ferry. The declaration is the primary source; the DB
|
|
46
|
+
effects are the independent verification.
|
|
47
|
+
|
|
48
|
+
Rejected alternative considered: a deterministic Crew API action for the
|
|
49
|
+
write. It would not remove the agent from the path (workflows reach the
|
|
50
|
+
CLI only through `agent()` shell calls), so it adds API surface for no
|
|
51
|
+
integrity gain. The embedded courier is the proven seam.
|
|
52
|
+
|
|
53
|
+
<a id="what-was-built"></a>
|
|
54
|
+
## What was built
|
|
55
|
+
|
|
56
|
+
- `workflows/crew-dispatch.js` §4c: per-tick declaration
|
|
57
|
+
`{seq, tick_seq, release, decision, launched[], completed[], parked[],
|
|
58
|
+
board{seen,eligible}, skipped{reason:count sparse}, partial}`.
|
|
59
|
+
`tick_seq` couples each line to the `.tick-releases.jsonl` line for the
|
|
60
|
+
same poll — a tick-release line with no decision line means the tick died
|
|
61
|
+
after poll-ack, visibly.
|
|
62
|
+
- Per-reason ineligibility counters (`countSkipped`) at every skip site in
|
|
63
|
+
the eligibility loop + the simultaneity-limit skip, so stand-downs are
|
|
64
|
+
auditable without re-deriving eligibility.
|
|
65
|
+
- Observer: `ingest_decisions.sh` replaces `attribute_dispatch.sh`;
|
|
66
|
+
`snapshot.sh` dispatch section rewritten around decision rows.
|
|
67
|
+
- Retired: sample→decision attribution, `state/attributed.json` watermark.
|
|
68
|
+
Kept: `record_platform.sh --dispatch` as platform-health telemetry,
|
|
69
|
+
`quarantine_suspects.sh` for the health samples' ID mutations.
|
|
@@ -988,7 +988,8 @@ cmd_integration_target() {
|
|
|
988
988
|
integration_target
|
|
989
989
|
}
|
|
990
990
|
|
|
991
|
-
# Branch-state classification for Review entry (blocker 34, 2026-09-21
|
|
991
|
+
# Branch-state classification for Review entry (blocker 34, 2026-09-21;
|
|
992
|
+
# no-branch path blocker 50, 2026-09-23).
|
|
992
993
|
# Usage: classify-branch <task_id>
|
|
993
994
|
# Stdout: exactly one successful line —
|
|
994
995
|
# BRANCH_STATE: has-work | already-merged:<40-hex> | empty-no-work
|
|
@@ -1005,17 +1006,72 @@ cmd_integration_target() {
|
|
|
1005
1006
|
# 1. current-tip identity — the live merge carrying the current branch
|
|
1006
1007
|
# tip (current_branch_merge, blocker 35's identity primitive). If the
|
|
1007
1008
|
# tip is on the live line via a merge, that merge IS the deliverable.
|
|
1009
|
+
# Branch-present path only — a gone branch has no tip to identify.
|
|
1008
1010
|
# 2. the task's own durable merge records, newest first (at most 20
|
|
1009
1011
|
# candidates scanned; garbage/unresolvable entries skipped). The
|
|
1010
1012
|
# first record that resolves to a MERGE commit and is an ancestor of
|
|
1011
1013
|
# the live target wins; the SHA is canonicalized to full 40-hex.
|
|
1012
1014
|
# Poison rule: the newest RESOLVABLE non-ancestor record poisons the
|
|
1013
1015
|
# fallback — an older record is never accepted beneath a newer known-
|
|
1014
|
-
# stale one.
|
|
1015
|
-
# never a fabricated already-merged.
|
|
1016
|
+
# stale one. On the branch-present path the answer is empty-no-work
|
|
1017
|
+
# (stderr notes the poison), never a fabricated already-merged. On the
|
|
1018
|
+
# no-branch path the poison fails closed (exit 1): without a branch,
|
|
1019
|
+
# "empty" is indistinguishable from evidence loss.
|
|
1016
1020
|
# The branch is empty here, so the blocker-35 identity/ancestry
|
|
1017
1021
|
# distinction does not apply: there is no moved branch to go stale
|
|
1018
1022
|
# against, and every candidate is this task's own record.
|
|
1023
|
+
#
|
|
1024
|
+
# No-branch path (blocker 50): a missing branch is the normal
|
|
1025
|
+
# post-integrate state — Integrate merges, the worktree lifecycle reclaims
|
|
1026
|
+
# the branch, and QA/rework run after. The classifier resolves the
|
|
1027
|
+
# integration target independently of the branch and runs the durable
|
|
1028
|
+
# merge-record scan (step 2 below). A hit prints already-merged:<sha> with
|
|
1029
|
+
# the real DIAG counts, exit 0. No hit — no records, none resolvable, none
|
|
1030
|
+
# an ancestor, or poisoned — exits 1 with diagnostics, never empty-no-work.
|
|
1031
|
+
|
|
1032
|
+
# Durable merge-record scan, shared by classify-branch's branch-present
|
|
1033
|
+
# and no-branch paths (blocker 50). Newest first, at most 20 candidates:
|
|
1034
|
+
# valid-hex merge_commit= lines only; a candidate must resolve to a MERGE
|
|
1035
|
+
# commit (^2 exists) to count as resolvable. The first resolvable record
|
|
1036
|
+
# that is an ancestor of $2 wins. Results in SCAN_MERGED (full 40-hex or
|
|
1037
|
+
# empty) and SCAN_POISONED (1 when the newest resolvable record is a
|
|
1038
|
+
# non-ancestor — an older ancestor beneath it is refused, stderr-noted).
|
|
1039
|
+
# Updates the caller's diag_records/diag_resolvable (dynamic scope; the
|
|
1040
|
+
# defaults keep set -u safe). No stdout of its own.
|
|
1041
|
+
scan_merge_records() {
|
|
1042
|
+
local task_id="$1" target="$2"
|
|
1043
|
+
SCAN_MERGED=""; SCAN_POISONED=0
|
|
1044
|
+
: "${diag_records:=0}" "${diag_resolvable:=0}"
|
|
1045
|
+
local rec="$MERGE_RECORDS_DIR/$task_id"
|
|
1046
|
+
[ -f "$rec" ] || return 0
|
|
1047
|
+
local count=0 sha full poisoned=""
|
|
1048
|
+
while IFS= read -r line && [ "$count" -lt 20 ]; do
|
|
1049
|
+
case "$line" in
|
|
1050
|
+
merge_commit=*) sha="${line#merge_commit=}" ;;
|
|
1051
|
+
*) continue ;;
|
|
1052
|
+
esac
|
|
1053
|
+
case "$sha" in ""|*[!0-9a-f]*) continue ;; esac
|
|
1054
|
+
count=$((count + 1))
|
|
1055
|
+
diag_records=$((diag_records + 1))
|
|
1056
|
+
full=$(git rev-parse --verify --quiet "$sha" 2>/dev/null) || continue
|
|
1057
|
+
# A merge commit: the frozen first-parent diff the workflow reviews
|
|
1058
|
+
# only exists for merges. A non-merge ancestor is never the task's
|
|
1059
|
+
# merge — skipped, not poison (garbage must not veto delivery).
|
|
1060
|
+
git rev-parse --verify --quiet "$full^2" >/dev/null 2>&1 || continue
|
|
1061
|
+
diag_resolvable=$((diag_resolvable + 1))
|
|
1062
|
+
if git merge-base --is-ancestor "$full" "$target" 2>/dev/null; then
|
|
1063
|
+
if [ -n "$poisoned" ]; then
|
|
1064
|
+
echo "classify-branch: newest resolvable record $poisoned is stale (not an ancestor of $target); refusing older record $full" >&2
|
|
1065
|
+
SCAN_POISONED=1
|
|
1066
|
+
return 0
|
|
1067
|
+
fi
|
|
1068
|
+
SCAN_MERGED="$full"
|
|
1069
|
+
return 0
|
|
1070
|
+
fi
|
|
1071
|
+
[ -z "$poisoned" ] && poisoned="$full"
|
|
1072
|
+
done < <(tac "$rec" 2>/dev/null)
|
|
1073
|
+
return 0
|
|
1074
|
+
}
|
|
1019
1075
|
cmd_classify_branch() {
|
|
1020
1076
|
local task_id="$1"
|
|
1021
1077
|
validate_task_id "$task_id"
|
|
@@ -1035,7 +1091,29 @@ cmd_classify_branch() {
|
|
|
1035
1091
|
branch=$(resolve_branch "$task_id") || { echo "classify-branch: cannot resolve task branch" >&2; diag; return 1; }
|
|
1036
1092
|
|
|
1037
1093
|
cd "$REPO"
|
|
1038
|
-
|
|
1094
|
+
|
|
1095
|
+
# Blocker 50: a missing branch is the normal post-integrate state, not a
|
|
1096
|
+
# classification failure — Integrate merges, the worktree lifecycle
|
|
1097
|
+
# reclaims the branch, and QA/rework run after. Resolve the target
|
|
1098
|
+
# independently of the branch and let the durable merge records plus
|
|
1099
|
+
# trunk ancestry decide. Never empty-no-work here: without a branch,
|
|
1100
|
+
# "empty" is indistinguishable from evidence loss.
|
|
1101
|
+
if ! git rev-parse --verify "$branch" >/dev/null 2>&1; then
|
|
1102
|
+
echo "classify-branch: branch $branch does not exist; scanning durable merge records" >&2
|
|
1103
|
+
local nb_target
|
|
1104
|
+
nb_target=$(integration_target) || { echo "classify-branch: cannot resolve integration target" >&2; diag; return 1; }
|
|
1105
|
+
scan_merge_records "$task_id" "$nb_target"
|
|
1106
|
+
if [ -n "$SCAN_MERGED" ]; then
|
|
1107
|
+
diag
|
|
1108
|
+
echo "BRANCH_STATE: already-merged:$SCAN_MERGED"
|
|
1109
|
+
return 0
|
|
1110
|
+
fi
|
|
1111
|
+
if [ "$SCAN_POISONED" != "1" ]; then
|
|
1112
|
+
echo "classify-branch: no merge record for $task_id is an ancestor of $nb_target" >&2
|
|
1113
|
+
fi
|
|
1114
|
+
diag
|
|
1115
|
+
return 1
|
|
1116
|
+
fi
|
|
1039
1117
|
|
|
1040
1118
|
local target
|
|
1041
1119
|
target=$(integration_target) || { echo "classify-branch: cannot resolve integration target" >&2; diag; return 1; }
|
|
@@ -1059,36 +1137,13 @@ cmd_classify_branch() {
|
|
|
1059
1137
|
fi
|
|
1060
1138
|
fi
|
|
1061
1139
|
|
|
1062
|
-
# 2. Durable merge records, newest first
|
|
1063
|
-
#
|
|
1064
|
-
|
|
1065
|
-
if [ -
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
merge_commit=*) sha="${line#merge_commit=}" ;;
|
|
1070
|
-
*) continue ;;
|
|
1071
|
-
esac
|
|
1072
|
-
case "$sha" in ""|*[!0-9a-f]*) continue ;; esac
|
|
1073
|
-
count=$((count + 1))
|
|
1074
|
-
diag_records=$((diag_records + 1))
|
|
1075
|
-
full=$(git rev-parse --verify --quiet "$sha" 2>/dev/null) || continue
|
|
1076
|
-
# A merge commit: the frozen first-parent diff the workflow reviews
|
|
1077
|
-
# only exists for merges. A non-merge ancestor is never the task's
|
|
1078
|
-
# merge — skipped, not poison (garbage must not veto delivery).
|
|
1079
|
-
git rev-parse --verify --quiet "$full^2" >/dev/null 2>&1 || continue
|
|
1080
|
-
diag_resolvable=$((diag_resolvable + 1))
|
|
1081
|
-
if git merge-base --is-ancestor "$full" "$target" 2>/dev/null; then
|
|
1082
|
-
if [ -n "$poisoned" ]; then
|
|
1083
|
-
echo "classify-branch: newest resolvable record $poisoned is stale (not an ancestor of $target); refusing older record $full" >&2
|
|
1084
|
-
break
|
|
1085
|
-
fi
|
|
1086
|
-
diag
|
|
1087
|
-
echo "BRANCH_STATE: already-merged:$full"
|
|
1088
|
-
return 0
|
|
1089
|
-
fi
|
|
1090
|
-
[ -z "$poisoned" ] && poisoned="$full"
|
|
1091
|
-
done < <(tac "$rec" 2>/dev/null)
|
|
1140
|
+
# 2. Durable merge records, newest first (scan_merge_records; blocker 50
|
|
1141
|
+
# shares this scan with the no-branch path).
|
|
1142
|
+
scan_merge_records "$task_id" "$target"
|
|
1143
|
+
if [ -n "$SCAN_MERGED" ]; then
|
|
1144
|
+
diag
|
|
1145
|
+
echo "BRANCH_STATE: already-merged:$SCAN_MERGED"
|
|
1146
|
+
return 0
|
|
1092
1147
|
fi
|
|
1093
1148
|
|
|
1094
1149
|
diag
|
package/package.json
CHANGED
|
@@ -561,6 +561,12 @@ const eligible = [];
|
|
|
561
561
|
const retryCandidates = [];
|
|
562
562
|
const rejectionCandidates = [];
|
|
563
563
|
|
|
564
|
+
// Dispatch-decision log (2026-09-24): per-reason ineligibility counters for
|
|
565
|
+
// the .dispatch-decisions.jsonl declaration. Sparse — only non-zero keys are
|
|
566
|
+
// emitted. Additive only; the eligibility decisions below are unchanged.
|
|
567
|
+
var decisionSkipped = {};
|
|
568
|
+
function countSkipped(reason) { decisionSkipped[reason] = (decisionSkipped[reason] || 0) + 1; }
|
|
569
|
+
|
|
564
570
|
// ── Retry cap ────────────────────────────────────────────────────────
|
|
565
571
|
// Symphony owns phase-redispatch policy (coordination layer): the spec
|
|
566
572
|
// defines backoff but no attempt cap ("implementation-defined"), so the
|
|
@@ -591,12 +597,13 @@ var MAX_CONSECUTIVE_REJECTIONS = configInt(config, "maxConsecutiveRejections", 2
|
|
|
591
597
|
|
|
592
598
|
for (var t = 0; t < allTasks.length; t++) {
|
|
593
599
|
var task = allTasks[t];
|
|
594
|
-
if (task.blocked) continue;
|
|
600
|
+
if (task.blocked) { countSkipped("blocked"); continue; }
|
|
595
601
|
|
|
596
602
|
// Skip tasks with an active dispatch reservation: the worker launched a
|
|
597
603
|
// workflow for this task on a previous tick, but the workflow has not yet
|
|
598
604
|
// self-claimed (claims can take 15+ minutes for cron-launched runs).
|
|
599
605
|
if (reservedTaskIds.has(task.id)) {
|
|
606
|
+
countSkipped("reserved");
|
|
600
607
|
log("Skipped \"" + task.title + "\" — active dispatch reservation (workflow launched, claim pending)");
|
|
601
608
|
continue;
|
|
602
609
|
}
|
|
@@ -604,6 +611,7 @@ for (var t = 0; t < allTasks.length; t++) {
|
|
|
604
611
|
// Skip tasks from quiesced projects
|
|
605
612
|
var taskProject = task.project || DEFAULT_PROJECT;
|
|
606
613
|
if (quiescedProjects[taskProject]) {
|
|
614
|
+
countSkipped("quiesced");
|
|
607
615
|
log("Skipped \"" + task.title + "\" — project " + taskProject + " is quiesced");
|
|
608
616
|
continue;
|
|
609
617
|
}
|
|
@@ -614,6 +622,7 @@ for (var t = 0; t < allTasks.length; t++) {
|
|
|
614
622
|
// task never moves. Set repo_path via updateproject to re-enable.
|
|
615
623
|
var taskProjCfg = PROJECTS[taskProject];
|
|
616
624
|
if (!taskProjCfg || !taskProjCfg.repo_path) {
|
|
625
|
+
countSkipped("no_repo");
|
|
617
626
|
log("Skipped \"" + task.title + "\" — project " + taskProject + " has no repo_path configured");
|
|
618
627
|
continue;
|
|
619
628
|
}
|
|
@@ -633,12 +642,14 @@ for (var t = 0; t < allTasks.length; t++) {
|
|
|
633
642
|
// (clears) next_phase on its successful self-claim, exactly once.
|
|
634
643
|
if (task.next_phase) {
|
|
635
644
|
if (task.state !== "todo" && task.state !== "in_progress") {
|
|
645
|
+
countSkipped("next_phase_state");
|
|
636
646
|
log("Skipped \"" + task.title + "\" — next_phase \"" + task.next_phase + "\" set but state is " + task.state + "; left set for inspection");
|
|
637
647
|
continue;
|
|
638
648
|
}
|
|
639
|
-
if (latest && latest.status === "running") continue; // work in flight
|
|
649
|
+
if (latest && latest.status === "running") { countSkipped("in_flight"); continue; } // work in flight
|
|
640
650
|
var npIdx = steps.indexOf(task.next_phase);
|
|
641
651
|
if (npIdx < 0) {
|
|
652
|
+
countSkipped("next_phase_bad_step");
|
|
642
653
|
log("Skipped \"" + task.title + "\" — next_phase \"" + task.next_phase + "\" not in " + workflow + " step registry; left set for a corrected recover-task");
|
|
643
654
|
continue;
|
|
644
655
|
}
|
|
@@ -654,19 +665,20 @@ for (var t = 0; t < allTasks.length; t++) {
|
|
|
654
665
|
continue;
|
|
655
666
|
}
|
|
656
667
|
|
|
657
|
-
if (task.state !== "in_progress") continue;
|
|
668
|
+
if (task.state !== "in_progress") { countSkipped("terminal_state"); continue; }
|
|
658
669
|
|
|
659
670
|
if (!latest) {
|
|
660
671
|
eligible.push({ task: task, startStep: 0, reason: "no_session", workflow: workflow });
|
|
661
672
|
continue;
|
|
662
673
|
}
|
|
663
674
|
|
|
664
|
-
if (latest.status === "running") continue; // work in flight
|
|
675
|
+
if (latest.status === "running") { countSkipped("in_flight"); continue; } // work in flight
|
|
665
676
|
|
|
666
677
|
if (latest.status === "completed") {
|
|
667
678
|
var stepName = latest.step || "";
|
|
668
679
|
var stepIndex = steps.indexOf(stepName);
|
|
669
680
|
if (stepIndex < 0) {
|
|
681
|
+
countSkipped("unknown_step");
|
|
670
682
|
log("Skipped \"" + task.title + "\" — step \"" + stepName + "\" not in " + workflow + " step registry");
|
|
671
683
|
continue;
|
|
672
684
|
}
|
|
@@ -695,6 +707,7 @@ for (var t = 0; t < allTasks.length; t++) {
|
|
|
695
707
|
var failedStep = latest.step || "";
|
|
696
708
|
var retryIdx = steps.indexOf(failedStep);
|
|
697
709
|
if (retryIdx < 0) {
|
|
710
|
+
countSkipped("unknown_step");
|
|
698
711
|
log("Skipped \"" + task.title + "\" — step \"" + failedStep + "\" not in " + workflow + " step registry");
|
|
699
712
|
continue;
|
|
700
713
|
}
|
|
@@ -756,6 +769,7 @@ if (rejectionCandidates.length > 0) {
|
|
|
756
769
|
// the task stays retryable and is re-parked next tick, so this is
|
|
757
770
|
// fail-safe. The dashboard stamps retry_reset_at on parked→todo, which
|
|
758
771
|
// restarts both counters mechanically.
|
|
772
|
+
var parkedTaskIds = [];
|
|
759
773
|
if (parkJobs.length > 0) {
|
|
760
774
|
var parkSteps = [];
|
|
761
775
|
for (var pji = 0; pji < parkJobs.length; pji++) {
|
|
@@ -775,6 +789,7 @@ if (parkJobs.length > 0) {
|
|
|
775
789
|
{ key: "park-batch", label: "Parking " + parkJobs.length + " task(s) at retry cap" }
|
|
776
790
|
);
|
|
777
791
|
log("Parked " + parkJobs.length + " task(s)");
|
|
792
|
+
parkedTaskIds = parkJobs.map(function(pj) { return pj.task.id; });
|
|
778
793
|
} catch (parkErr) {
|
|
779
794
|
log("WARNING: park batch failed (" + (parkErr.message || String(parkErr)).slice(0, 200) + ") — tasks remain retryable");
|
|
780
795
|
}
|
|
@@ -959,6 +974,7 @@ for (var ei = 0; ei < eligible.length; ei++) {
|
|
|
959
974
|
toProcess.push(eitem);
|
|
960
975
|
inFlightByProject[ep] = current + 1;
|
|
961
976
|
} else {
|
|
977
|
+
countSkipped("at_limit");
|
|
962
978
|
log("Skipped \"" + eitem.task.title + "\" — project " + ep + " at simultaneity limit (" + limit + ")");
|
|
963
979
|
}
|
|
964
980
|
}
|
|
@@ -1164,6 +1180,63 @@ if (recommended.length > 0) {
|
|
|
1164
1180
|
recommended = acquired;
|
|
1165
1181
|
}
|
|
1166
1182
|
|
|
1183
|
+
// ── 4c. Dispatch-decision log ──────────────────────────────────
|
|
1184
|
+
// Hoverboat (2026-09-24): the dispatcher declares its own decision, in its
|
|
1185
|
+
// own bytes, on crew-home disk — one append-only line per completed tick in
|
|
1186
|
+
// $CREW_HOME/.dispatch-decisions.jsonl. The observer reads the file
|
|
1187
|
+
// directly (no LLM re-emission, no platform ferry), so the plausible-value
|
|
1188
|
+
// mutation that poisoned the old dispatch_sample attribution cannot occur.
|
|
1189
|
+
// Launch declarations are verified mechanically against crew DB effects
|
|
1190
|
+
// (platform_run_tasks / dispatch_reservations); a stand-down is the
|
|
1191
|
+
// dispatcher's explicit declaration with board context, auditable without
|
|
1192
|
+
// re-deriving eligibility. tick_seq couples each line to the .tick-releases
|
|
1193
|
+
// line for the same poll — a tick-release line with no decision line means
|
|
1194
|
+
// the tick died after poll-ack and is visible as such.
|
|
1195
|
+
// seq is the non-empty line count + 1 of this file (file order is the
|
|
1196
|
+
// proof — no wall-clock calls, see tests/determinism.test.js).
|
|
1197
|
+
// Fire-and-forget: the script swallows its own errors and exits 0, the
|
|
1198
|
+
// agent call carries no schema, and the whole call is wrapped in
|
|
1199
|
+
// try/catch — a failed write can never fail the tick. The script echoes
|
|
1200
|
+
// the appended line (same rooms #12–#14 empty-result contract as §4b).
|
|
1201
|
+
var dispatchDecisionScript = [
|
|
1202
|
+
"var crewHome=process.argv[1];",
|
|
1203
|
+
"var decisionJson=process.argv[2];",
|
|
1204
|
+
"try{",
|
|
1205
|
+
"var fs=require(\"fs\"),path=require(\"path\");",
|
|
1206
|
+
"var decision=JSON.parse(decisionJson);",
|
|
1207
|
+
"if(!decision||typeof decision.decision!==\"string\"||!Array.isArray(decision.launched)){throw new Error(\"bad decision payload\");}",
|
|
1208
|
+
"var release=path.basename(path.resolve(crewHome,fs.readlinkSync(path.join(crewHome,\"current\"))));",
|
|
1209
|
+
"var file=path.join(crewHome,\".dispatch-decisions.jsonl\");",
|
|
1210
|
+
"var count=0;",
|
|
1211
|
+
"try{var lines=fs.readFileSync(file,\"utf8\").split(\"\\n\");for(var i=0;i<lines.length;i++){if(lines[i].trim()!==\"\"){count++;}}}catch(e){}",
|
|
1212
|
+
"var tickSeq=0;",
|
|
1213
|
+
"try{var tlines=fs.readFileSync(path.join(crewHome,\".tick-releases.jsonl\"),\"utf8\").split(\"\\n\");for(var j=0;j<tlines.length;j++){if(tlines[j].trim()!==\"\"){tickSeq++;}}}catch(e){}",
|
|
1214
|
+
"var line=JSON.stringify({seq:count+1,tick_seq:tickSeq,release:release,decision:decision.decision,launched:decision.launched,completed:decision.completed||[],parked:decision.parked||[],board:decision.board||null,skipped:decision.skipped||{},partial:!!decision.partial});",
|
|
1215
|
+
"fs.appendFileSync(file,line+\"\\n\");",
|
|
1216
|
+
"process.stdout.write(line+\"\\n\");",
|
|
1217
|
+
"}catch(e){}",
|
|
1218
|
+
"process.exit(0);"
|
|
1219
|
+
].join("");
|
|
1220
|
+
var launchedRecs = recommended.map(function(r) { return { task_id: r.task_id, workflow: r.workflow, step: r.step }; });
|
|
1221
|
+
var decisionObj = {
|
|
1222
|
+
decision: launchedRecs.length > 0 ? "launch" : "stand_down",
|
|
1223
|
+
launched: launchedRecs,
|
|
1224
|
+
completed: completed.map(function(r) { return r.task_id; }),
|
|
1225
|
+
parked: parkedTaskIds,
|
|
1226
|
+
board: { seen: allTasks.length, eligible: eligible.length },
|
|
1227
|
+
skipped: decisionSkipped,
|
|
1228
|
+
partial: partial
|
|
1229
|
+
};
|
|
1230
|
+
var dispatchDecisionCmd = "node -e '" + dispatchDecisionScript + "' '" + crewHome.replace(/'/g, "'\\''") + "' '" + JSON.stringify(decisionObj).replace(/'/g, "'\\''") + "'";
|
|
1231
|
+
try {
|
|
1232
|
+
await agent(
|
|
1233
|
+
"Record this tick's dispatch decision.\nRun in shell and return the stdout verbatim:\n" + dispatchDecisionCmd,
|
|
1234
|
+
{ key: "dispatch-decision", label: "Recording dispatch decision" }
|
|
1235
|
+
);
|
|
1236
|
+
} catch (e) {
|
|
1237
|
+
log("WARNING: dispatch-decision log write failed (tick continues): " + e.message);
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1167
1240
|
var msg = "Dispatch complete.";
|
|
1168
1241
|
if (recommended.length > 0) {
|
|
1169
1242
|
msg += " Recommended: " + recommended.map(function(r) { return r.workflow + "/" + r.step + " for " + r.task_id; }).join(", ") + ".";
|