agent-control-plane 0.1.9 → 0.1.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/hooks/heartbeat-hooks.sh +147 -8
- package/hooks/issue-reconcile-hooks.sh +46 -0
- package/npm/bin/agent-control-plane.js +89 -8
- package/package.json +8 -2
- package/references/commands.md +1 -0
- package/tools/bin/agent-project-cleanup-session +133 -0
- package/tools/bin/agent-project-publish-issue-pr +178 -62
- package/tools/bin/agent-project-reconcile-issue-session +171 -3
- package/tools/bin/agent-project-run-codex-resilient +121 -16
- package/tools/bin/agent-project-run-codex-session +118 -10
- package/tools/bin/agent-project-run-openclaw-session +82 -8
- package/tools/bin/branch-verification-guard.sh +15 -2
- package/tools/bin/cleanup-worktree.sh +4 -1
- package/tools/bin/dashboard-launchd-bootstrap.sh +16 -4
- package/tools/bin/ensure-runtime-sync.sh +182 -0
- package/tools/bin/flow-config-lib.sh +76 -30
- package/tools/bin/flow-resident-worker-lib.sh +28 -2
- package/tools/bin/flow-shell-lib.sh +15 -1
- package/tools/bin/heartbeat-safe-auto.sh +32 -0
- package/tools/bin/issue-publish-localization-guard.sh +142 -0
- package/tools/bin/project-launchd-bootstrap.sh +17 -4
- package/tools/bin/project-runtime-supervisor.sh +7 -1
- package/tools/bin/project-runtimectl.sh +78 -15
- package/tools/bin/reuse-issue-worktree.sh +46 -0
- package/tools/bin/start-issue-worker.sh +110 -30
- package/tools/bin/start-resident-issue-loop.sh +1 -0
- package/tools/bin/sync-shared-agent-home.sh +50 -10
- package/tools/bin/test-smoke.sh +6 -1
- package/tools/dashboard/app.js +71 -1
- package/tools/dashboard/dashboard_snapshot.py +74 -0
- package/tools/dashboard/styles.css +43 -0
- package/tools/templates/issue-prompt-template.md +20 -65
- package/tools/templates/legacy/issue-prompt-template-pre-slim.md +109 -0
- package/bin/audit-issue-routing.sh +0 -74
- package/tools/bin/audit-agent-worktrees.sh +0 -310
- package/tools/bin/audit-issue-routing.sh +0 -11
- package/tools/bin/audit-retained-layout.sh +0 -58
- package/tools/bin/audit-retained-overlap.sh +0 -135
- package/tools/bin/audit-retained-worktrees.sh +0 -228
- package/tools/bin/check-skill-contracts.sh +0 -324
|
@@ -153,6 +153,54 @@ reap_stale_run_dir() {
|
|
|
153
153
|
mv "$RUN_DIR" "${HISTORY_ROOT}/${SESSION}-stale-$(date +%Y%m%d-%H%M%S)"
|
|
154
154
|
}
|
|
155
155
|
|
|
156
|
+
find_archived_issue_session_dir() {
|
|
157
|
+
local root="${1:-}"
|
|
158
|
+
local target_session="${2:-}"
|
|
159
|
+
[[ -n "$root" && -d "$root" && -n "$target_session" ]] || return 1
|
|
160
|
+
|
|
161
|
+
find "$root" -mindepth 1 -maxdepth 1 -type d -name "${target_session}-*" ! -name "${target_session}-stale-*" 2>/dev/null \
|
|
162
|
+
| sort -r \
|
|
163
|
+
| head -n 1
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
issue_retry_state_value() {
|
|
167
|
+
local key="${1:?retry-state key required}"
|
|
168
|
+
awk -F= -v target_key="$key" '$1 == target_key { print substr($0, index($0, "=") + 1); exit }' <<<"${ISSUE_RETRY_STATE:-}"
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
issue_host_publish_replay_dir() {
|
|
172
|
+
local last_reason=""
|
|
173
|
+
local archived_dir=""
|
|
174
|
+
local runner_state=""
|
|
175
|
+
local result_outcome=""
|
|
176
|
+
local result_action=""
|
|
177
|
+
|
|
178
|
+
last_reason="$(issue_retry_state_value LAST_REASON)"
|
|
179
|
+
case "${last_reason}" in
|
|
180
|
+
host-publish-failed|issue-worker-blocked) ;;
|
|
181
|
+
*) return 1 ;;
|
|
182
|
+
esac
|
|
183
|
+
|
|
184
|
+
archived_dir="$(find_archived_issue_session_dir "$HISTORY_ROOT" "$SESSION" || true)"
|
|
185
|
+
[[ -n "${archived_dir}" && -f "${archived_dir}/run.env" && -f "${archived_dir}/runner.env" && -f "${archived_dir}/result.env" ]] || return 1
|
|
186
|
+
|
|
187
|
+
runner_state="$(awk -F= '/^RUNNER_STATE=/{print $2; exit}' "${archived_dir}/runner.env")"
|
|
188
|
+
result_outcome="$(awk -F= '/^OUTCOME=/{print $2; exit}' "${archived_dir}/result.env")"
|
|
189
|
+
result_action="$(awk -F= '/^ACTION=/{print $2; exit}' "${archived_dir}/result.env")"
|
|
190
|
+
|
|
191
|
+
[[ "${runner_state}" == "succeeded" ]] || return 1
|
|
192
|
+
[[ "${result_outcome}" == "implemented" ]] || return 1
|
|
193
|
+
[[ "${result_action}" == "host-publish-issue-pr" ]] || return 1
|
|
194
|
+
|
|
195
|
+
printf '%s\n' "${archived_dir}"
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
replay_issue_host_publish_retry() {
|
|
199
|
+
local archived_dir="${1:?archived dir required}"
|
|
200
|
+
printf 'ISSUE_HOST_PUBLISH_REPLAY=session=%s archived_run_dir=%s\n' "${SESSION}" "${archived_dir}" >&2
|
|
201
|
+
bash "${WORKSPACE_DIR}/bin/reconcile-issue-worker.sh" "${SESSION}"
|
|
202
|
+
}
|
|
203
|
+
|
|
156
204
|
if tmux has-session -t "$SESSION" 2>/dev/null; then
|
|
157
205
|
echo "worker session already exists: $SESSION" >&2
|
|
158
206
|
exit 1
|
|
@@ -180,6 +228,9 @@ EOF
|
|
|
180
228
|
ISSUE_REQUIRES_LOCAL_WORKSPACE_INSTALL="$(
|
|
181
229
|
ISSUE_BODY="$ISSUE_BODY" bash "$LOCAL_INSTALL_POLICY_BIN"
|
|
182
230
|
)"
|
|
231
|
+
ISSUE_RETRY_STATE="$(
|
|
232
|
+
bash "${WORKSPACE_DIR}/bin/retry-state.sh" issue "$ISSUE_ID" get 2>/dev/null || true
|
|
233
|
+
)"
|
|
183
234
|
if [[ "${ISSUE_SCHEDULE_INTERVAL_SECONDS}" =~ ^[1-9][0-9]*$ ]]; then
|
|
184
235
|
TEMPLATE_FILE="${SCHEDULED_TEMPLATE_FILE}"
|
|
185
236
|
fi
|
|
@@ -227,6 +278,16 @@ if [[ -d "$RUN_DIR" ]]; then
|
|
|
227
278
|
reap_stale_run_dir
|
|
228
279
|
fi
|
|
229
280
|
|
|
281
|
+
ISSUE_HOST_PUBLISH_REPLAY_DIR="$(issue_host_publish_replay_dir || true)"
|
|
282
|
+
if [[ -n "${ISSUE_HOST_PUBLISH_REPLAY_DIR}" ]]; then
|
|
283
|
+
if ! replay_issue_host_publish_retry "${ISSUE_HOST_PUBLISH_REPLAY_DIR}"; then
|
|
284
|
+
echo "host publish replay failed for session ${SESSION}" >&2
|
|
285
|
+
exit 1
|
|
286
|
+
fi
|
|
287
|
+
launch_success="yes"
|
|
288
|
+
exit 0
|
|
289
|
+
fi
|
|
290
|
+
|
|
230
291
|
block_if_recurring_checklist_complete
|
|
231
292
|
|
|
232
293
|
mkdir -p "$RUN_DIR"
|
|
@@ -348,9 +409,6 @@ if (completedPrs.length > 0) {
|
|
|
348
409
|
process.stdout.write(`${lines.join('\n')}\n`);
|
|
349
410
|
EOF
|
|
350
411
|
ISSUE_RECURRING_CONTEXT="$(cat "$ISSUE_RECURRING_CONTEXT_FILE")"
|
|
351
|
-
ISSUE_RETRY_STATE="$(
|
|
352
|
-
bash "${WORKSPACE_DIR}/bin/retry-state.sh" issue "$ISSUE_ID" get 2>/dev/null || true
|
|
353
|
-
)"
|
|
354
412
|
ISSUE_BLOCKER_CONTEXT="$(
|
|
355
413
|
ISSUE_JSON="$ISSUE_JSON" ISSUE_RETRY_STATE="$ISSUE_RETRY_STATE" node <<'EOF'
|
|
356
414
|
const issue = JSON.parse(process.env.ISSUE_JSON || '{}');
|
|
@@ -401,6 +459,9 @@ const inferCommentReason = (bodyText) => {
|
|
|
401
459
|
if (/^# Blocker: Verification requirements were not satisfied$/im.test(body)) {
|
|
402
460
|
return 'verification-guard-blocked';
|
|
403
461
|
}
|
|
462
|
+
if (/^# Blocker: Localization requirements were not satisfied$/im.test(body)) {
|
|
463
|
+
return 'localization-guard-blocked';
|
|
464
|
+
}
|
|
404
465
|
if (/^# Blocker: (All checklist items already completed|Worker produced no publishable delta)$/im.test(body)) {
|
|
405
466
|
return 'no-publishable-commits';
|
|
406
467
|
}
|
|
@@ -468,6 +529,8 @@ if (effectiveLastReason === 'scope-guard-blocked') {
|
|
|
468
529
|
}
|
|
469
530
|
} else if (effectiveLastReason === 'verification-guard-blocked') {
|
|
470
531
|
lines.push('- Add the missing verification or shrink the touched surface before attempting another publish cycle.');
|
|
532
|
+
} else if (effectiveLastReason === 'localization-guard-blocked') {
|
|
533
|
+
lines.push('- Finish moving the remaining user-facing literals behind translation keys before attempting another publish cycle.');
|
|
471
534
|
}
|
|
472
535
|
|
|
473
536
|
lines.push('', clippedBody);
|
|
@@ -579,10 +642,11 @@ open_or_reuse_issue_worktree() {
|
|
|
579
642
|
RESIDENT_OPENCLAW_CONFIG_PATH="${current_resident_openclaw_config_path}"
|
|
580
643
|
RESIDENT_TASK_COUNT="$(( ${TASK_COUNT:-0} + 1 ))"
|
|
581
644
|
RESIDENT_WORKTREE_REUSED="yes"
|
|
582
|
-
if [[ "${CODING_WORKER}" == "openclaw"
|
|
645
|
+
if [[ "${CODING_WORKER}" == "openclaw" ]]; then
|
|
583
646
|
# Keep the resident lane's warm workspace/agent files, but rotate the
|
|
584
|
-
# OpenClaw conversation thread
|
|
585
|
-
|
|
647
|
+
# OpenClaw conversation thread every cycle so a new task does not inherit
|
|
648
|
+
# stale conversational context from the previous one.
|
|
649
|
+
RESIDENT_OPENCLAW_SESSION_ID="$(flow_resident_issue_openclaw_session_id "${CONFIG_YAML}" "${current_issue_id}" "${RESIDENT_TASK_COUNT}")"
|
|
586
650
|
fi
|
|
587
651
|
if reuse_output="$("${WORKSPACE_DIR}/bin/reuse-issue-worktree.sh" "${WORKTREE}" "${ISSUE_ID}" "${ISSUE_SLUG}" 2>&1)"; then
|
|
588
652
|
WORKTREE_OUT="${reuse_output}"
|
|
@@ -590,6 +654,9 @@ open_or_reuse_issue_worktree() {
|
|
|
590
654
|
printf 'RESIDENT_REUSE_FALLBACK=issue-%s reason=%s\n' "${ISSUE_ID}" "$(printf '%s' "${reuse_output}" | tr '\n' ' ' | sed 's/ */ /g')" >&2
|
|
591
655
|
RESIDENT_TASK_COUNT="1"
|
|
592
656
|
RESIDENT_WORKTREE_REUSED="no"
|
|
657
|
+
if [[ "${CODING_WORKER}" == "openclaw" ]]; then
|
|
658
|
+
RESIDENT_OPENCLAW_SESSION_ID="$(flow_resident_issue_openclaw_session_id "${CONFIG_YAML}" "${current_issue_id}" "${RESIDENT_TASK_COUNT}")"
|
|
659
|
+
fi
|
|
593
660
|
if [[ "$ISSUE_REQUIRES_LOCAL_WORKSPACE_INSTALL" == "yes" ]]; then
|
|
594
661
|
WORKTREE_OUT="$(ACP_WORKTREE_LOCAL_INSTALL=true F_LOSNING_WORKTREE_LOCAL_INSTALL=true "${WORKSPACE_DIR}/bin/new-worktree.sh" "$ISSUE_ID" "$ISSUE_SLUG")"
|
|
595
662
|
else
|
|
@@ -599,6 +666,9 @@ open_or_reuse_issue_worktree() {
|
|
|
599
666
|
else
|
|
600
667
|
RESIDENT_TASK_COUNT="1"
|
|
601
668
|
RESIDENT_WORKTREE_REUSED="no"
|
|
669
|
+
if [[ "${CODING_WORKER}" == "openclaw" ]]; then
|
|
670
|
+
RESIDENT_OPENCLAW_SESSION_ID="$(flow_resident_issue_openclaw_session_id "${CONFIG_YAML}" "${current_issue_id}" "${RESIDENT_TASK_COUNT}")"
|
|
671
|
+
fi
|
|
602
672
|
if [[ "$ISSUE_REQUIRES_LOCAL_WORKSPACE_INSTALL" == "yes" ]]; then
|
|
603
673
|
WORKTREE_OUT="$(ACP_WORKTREE_LOCAL_INSTALL=true F_LOSNING_WORKTREE_LOCAL_INSTALL=true "${WORKSPACE_DIR}/bin/new-worktree.sh" "$ISSUE_ID" "$ISSUE_SLUG")"
|
|
604
674
|
else
|
|
@@ -657,38 +727,48 @@ for (const line of body.split(/\r?\n/).slice(0, 40)) {
|
|
|
657
727
|
}
|
|
658
728
|
}
|
|
659
729
|
|
|
660
|
-
if (commands.length === 0 && repoRoot) {
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
addCommand('pnpm test');
|
|
668
|
-
} else if (fs.existsSync(path.join(repoRoot, 'yarn.lock'))) {
|
|
669
|
-
addCommand('yarn test');
|
|
670
|
-
} else {
|
|
671
|
-
addCommand('npm test');
|
|
730
|
+
if (commands.length === 0 && repoRoot) {
|
|
731
|
+
const packageJsonPath = path.join(repoRoot, 'package.json');
|
|
732
|
+
if (fs.existsSync(packageJsonPath)) {
|
|
733
|
+
try {
|
|
734
|
+
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
|
|
735
|
+
if (packageJson?.scripts?.smoke) {
|
|
736
|
+
addCommand('# If this cycle changes smoke runners, CLI entrypoints, or operator commands, also run the matching smoke command from the repo instructions.');
|
|
672
737
|
}
|
|
738
|
+
} catch (_error) {
|
|
739
|
+
// Ignore parse errors and fall through to generic guidance.
|
|
673
740
|
}
|
|
674
|
-
} catch (_error) {
|
|
675
|
-
// Ignore parse errors and fall through to generic guidance.
|
|
676
741
|
}
|
|
677
742
|
}
|
|
678
|
-
}
|
|
679
743
|
|
|
680
|
-
if (commands.length === 0) {
|
|
681
|
-
|
|
682
|
-
|
|
744
|
+
if (commands.length === 0) {
|
|
745
|
+
addCommand('# Pick the narrowest relevant local verification for the files you touch.');
|
|
746
|
+
addCommand('# Do not default to repo-wide pnpm test unless the issue body explicitly requires it.');
|
|
747
|
+
addCommand('# Examples:');
|
|
748
|
+
addCommand('# pnpm --filter @<repo>/api test -- --runInBand <target-spec>');
|
|
749
|
+
addCommand('# pnpm --filter @<repo>/api typecheck');
|
|
750
|
+
addCommand('# pnpm --filter @<repo>/web test -- --run <target-spec>');
|
|
751
|
+
addCommand('# pnpm --filter @<repo>/web typecheck');
|
|
752
|
+
addCommand('# pnpm --filter @<repo>/mobile test -- --runInBand <target-spec>');
|
|
753
|
+
addCommand('# pnpm --filter @<repo>/mobile typecheck');
|
|
754
|
+
addCommand('# pnpm --filter @<repo>/<package> test -- --run <target-spec>');
|
|
755
|
+
addCommand('# pnpm --filter @<repo>/<package> typecheck');
|
|
756
|
+
addCommand('# After each successful command, record it with record-verification.sh exactly as shown below.');
|
|
757
|
+
}
|
|
683
758
|
|
|
684
759
|
const escapeDoubleQuotes = (value) => value.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
|
685
760
|
const snippet = commands
|
|
686
|
-
.map((command) =>
|
|
687
|
-
command
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
761
|
+
.map((command) => {
|
|
762
|
+
if (command.startsWith('#')) {
|
|
763
|
+
return command;
|
|
764
|
+
}
|
|
765
|
+
return (
|
|
766
|
+
command + '\n' +
|
|
767
|
+
'bash "$ACP_FLOW_TOOLS_DIR/record-verification.sh" --run-dir "$ACP_RUN_DIR" --status pass --command "' +
|
|
768
|
+
escapeDoubleQuotes(command) +
|
|
769
|
+
'"'
|
|
770
|
+
);
|
|
771
|
+
})
|
|
692
772
|
.join('\n\n');
|
|
693
773
|
|
|
694
774
|
process.stdout.write(snippet);
|
|
@@ -785,6 +785,7 @@ while true; do
|
|
|
785
785
|
controller_refresh_execution_context
|
|
786
786
|
controller_refresh_issue_lane_context "${is_scheduled}" "${schedule_interval_seconds}"
|
|
787
787
|
controller_track_provider_selection "provider-selection"
|
|
788
|
+
controller_write_state "starting" ""
|
|
788
789
|
|
|
789
790
|
if controller_yield_to_live_lane_peer; then
|
|
790
791
|
break
|
|
@@ -32,12 +32,21 @@ if [[ ! -d "${FLOW_SKILL_SOURCE}" && -n "${COMPAT_FLOW_SKILL_ALIAS}" ]]; then
|
|
|
32
32
|
fi
|
|
33
33
|
|
|
34
34
|
FLOW_SKILL_SOURCE="$(cd "${FLOW_SKILL_SOURCE}" && pwd -P)"
|
|
35
|
+
SOURCE_HOME="$(cd "${SOURCE_HOME}" && pwd -P)"
|
|
36
|
+
|
|
37
|
+
remove_tree_force() {
|
|
38
|
+
local target="${1:-}"
|
|
39
|
+
[[ -n "${target}" ]] || return 0
|
|
40
|
+
[[ -e "${target}" || -L "${target}" ]] || return 0
|
|
41
|
+
chmod -R u+w "${target}" 2>/dev/null || true
|
|
42
|
+
rm -rf "${target}" 2>/dev/null || true
|
|
43
|
+
}
|
|
35
44
|
|
|
36
45
|
sync_tree_copy_mode() {
|
|
37
46
|
local source_dir="${1:?source dir required}"
|
|
38
47
|
local target_dir="${2:?target dir required}"
|
|
39
48
|
[[ -d "${source_dir}" ]] || return 0
|
|
40
|
-
|
|
49
|
+
remove_tree_force "${target_dir}"
|
|
41
50
|
mkdir -p "${target_dir}"
|
|
42
51
|
(
|
|
43
52
|
cd "${source_dir}"
|
|
@@ -57,18 +66,45 @@ sync_tree_into_target() {
|
|
|
57
66
|
}
|
|
58
67
|
|
|
59
68
|
sync_skill_copies() {
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
69
|
+
if ! flow_is_skill_root "${SOURCE_HOME}"; then
|
|
70
|
+
sync_tree_into_target "${FLOW_SKILL_SOURCE}" "${SOURCE_FLOW_CANONICAL_ALIAS}"
|
|
71
|
+
if [[ -n "${SOURCE_FLOW_COMPAT_ALIAS}" ]]; then
|
|
72
|
+
sync_tree_into_target "${FLOW_SKILL_SOURCE}" "${SOURCE_FLOW_COMPAT_ALIAS}"
|
|
73
|
+
fi
|
|
65
74
|
fi
|
|
66
75
|
|
|
76
|
+
sync_tree_into_target "${FLOW_SKILL_SOURCE}" "${FLOW_SKILL_TARGET}"
|
|
77
|
+
|
|
67
78
|
if [[ -n "${TARGET_FLOW_COMPAT_ALIAS}" ]]; then
|
|
68
79
|
sync_tree_into_target "${FLOW_SKILL_SOURCE}" "${TARGET_FLOW_COMPAT_ALIAS}"
|
|
69
80
|
fi
|
|
70
81
|
}
|
|
71
82
|
|
|
83
|
+
refresh_legacy_profile_templates() {
|
|
84
|
+
local profiles_root=""
|
|
85
|
+
local current_issue_template=""
|
|
86
|
+
local legacy_issue_template=""
|
|
87
|
+
local profile_dir=""
|
|
88
|
+
local profile_issue_template=""
|
|
89
|
+
|
|
90
|
+
profiles_root="$(resolve_flow_profile_registry_root)"
|
|
91
|
+
current_issue_template="${FLOW_SKILL_SOURCE}/tools/templates/issue-prompt-template.md"
|
|
92
|
+
legacy_issue_template="${FLOW_SKILL_SOURCE}/tools/templates/legacy/issue-prompt-template-pre-slim.md"
|
|
93
|
+
|
|
94
|
+
[[ -d "${profiles_root}" ]] || return 0
|
|
95
|
+
[[ -f "${current_issue_template}" ]] || return 0
|
|
96
|
+
[[ -f "${legacy_issue_template}" ]] || return 0
|
|
97
|
+
|
|
98
|
+
while IFS= read -r profile_dir; do
|
|
99
|
+
[[ -n "${profile_dir}" ]] || continue
|
|
100
|
+
profile_issue_template="${profile_dir}/templates/issue-prompt-template.md"
|
|
101
|
+
[[ -f "${profile_issue_template}" ]] || continue
|
|
102
|
+
if cmp -s "${profile_issue_template}" "${legacy_issue_template}"; then
|
|
103
|
+
cp "${current_issue_template}" "${profile_issue_template}"
|
|
104
|
+
fi
|
|
105
|
+
done < <(find "${profiles_root}" -mindepth 2 -maxdepth 2 -type f -name 'control-plane.yaml' -exec dirname {} \; 2>/dev/null | sort)
|
|
106
|
+
}
|
|
107
|
+
|
|
72
108
|
remove_repo_local_profile_dirs() {
|
|
73
109
|
local candidate=""
|
|
74
110
|
|
|
@@ -175,18 +211,21 @@ sync_tree_rsync() {
|
|
|
175
211
|
}
|
|
176
212
|
|
|
177
213
|
reset_runtime_skill_targets() {
|
|
178
|
-
|
|
214
|
+
remove_tree_force "${FLOW_SKILL_TARGET}"
|
|
179
215
|
if [[ -n "${TARGET_FLOW_COMPAT_ALIAS}" ]]; then
|
|
180
|
-
|
|
216
|
+
remove_tree_force "${TARGET_FLOW_COMPAT_ALIAS}"
|
|
181
217
|
fi
|
|
182
218
|
}
|
|
183
219
|
|
|
184
220
|
reset_source_skill_targets() {
|
|
221
|
+
if flow_is_skill_root "${SOURCE_HOME}"; then
|
|
222
|
+
return 0
|
|
223
|
+
fi
|
|
185
224
|
if [[ "${FLOW_SKILL_SOURCE}" != "${SOURCE_FLOW_CANONICAL_ALIAS}" ]]; then
|
|
186
|
-
|
|
225
|
+
remove_tree_force "${SOURCE_FLOW_CANONICAL_ALIAS}"
|
|
187
226
|
fi
|
|
188
227
|
if [[ -n "${SOURCE_FLOW_COMPAT_ALIAS}" && "${FLOW_SKILL_SOURCE}" != "${SOURCE_FLOW_COMPAT_ALIAS}" ]]; then
|
|
189
|
-
|
|
228
|
+
remove_tree_force "${SOURCE_FLOW_COMPAT_ALIAS}"
|
|
190
229
|
fi
|
|
191
230
|
}
|
|
192
231
|
|
|
@@ -210,5 +249,6 @@ fi
|
|
|
210
249
|
sync_skill_copies
|
|
211
250
|
remove_repo_local_profile_dirs
|
|
212
251
|
normalize_script_permissions
|
|
252
|
+
refresh_legacy_profile_templates
|
|
213
253
|
|
|
214
254
|
printf 'SHARED_AGENT_HOME=%s\n' "${TARGET_HOME}"
|
package/tools/bin/test-smoke.sh
CHANGED
|
@@ -58,7 +58,12 @@ run_step() {
|
|
|
58
58
|
return "${status}"
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
-
|
|
61
|
+
if [[ -f "${check_contracts_script}" ]]; then
|
|
62
|
+
run_step "check-skill-contracts" bash "${check_contracts_script}"
|
|
63
|
+
else
|
|
64
|
+
printf 'SMOKE_STEP=%s\n' "check-skill-contracts"
|
|
65
|
+
printf 'SMOKE_STEP_STATUS=%s\n' "skipped"
|
|
66
|
+
fi
|
|
62
67
|
|
|
63
68
|
run_profile_smoke_fixture() (
|
|
64
69
|
set -euo pipefail
|
package/tools/dashboard/app.js
CHANGED
|
@@ -2,6 +2,8 @@ const refreshButton = document.querySelector("#refresh-button");
|
|
|
2
2
|
const generatedAtNode = document.querySelector("#generated-at");
|
|
3
3
|
const overviewNode = document.querySelector("#overview");
|
|
4
4
|
const profilesNode = document.querySelector("#profiles");
|
|
5
|
+
const seenAlertIds = new Set();
|
|
6
|
+
let notificationPermissionRequested = false;
|
|
5
7
|
|
|
6
8
|
function relativeTime(input) {
|
|
7
9
|
if (!input) return "n/a";
|
|
@@ -61,9 +63,10 @@ function renderOverview(snapshot) {
|
|
|
61
63
|
acc.controllers += profile.counts.live_resident_controllers;
|
|
62
64
|
acc.cooldowns += profile.counts.provider_cooldowns;
|
|
63
65
|
acc.queue += profile.counts.queued_issues;
|
|
66
|
+
acc.alerts += profile.counts.alerts || 0;
|
|
64
67
|
return acc;
|
|
65
68
|
},
|
|
66
|
-
{ activeRuns: 0, runningRuns: 0, implementedRuns: 0, reportedRuns: 0, blockedRuns: 0, controllers: 0, cooldowns: 0, queue: 0 },
|
|
69
|
+
{ activeRuns: 0, runningRuns: 0, implementedRuns: 0, reportedRuns: 0, blockedRuns: 0, controllers: 0, cooldowns: 0, queue: 0, alerts: 0 },
|
|
67
70
|
);
|
|
68
71
|
|
|
69
72
|
overviewNode.innerHTML = [
|
|
@@ -75,6 +78,7 @@ function renderOverview(snapshot) {
|
|
|
75
78
|
["Blocked", totals.blockedRuns],
|
|
76
79
|
["Live Controllers", totals.controllers],
|
|
77
80
|
["Provider Cooldowns", totals.cooldowns],
|
|
81
|
+
["Alerts", totals.alerts],
|
|
78
82
|
["Queued Issues", totals.queue],
|
|
79
83
|
]
|
|
80
84
|
.map(
|
|
@@ -104,6 +108,36 @@ function renderTable(columns, rows, emptyMessage = "No data right now.") {
|
|
|
104
108
|
return `<div class="table-wrap"><table><thead><tr>${headers}</tr></thead><tbody>${body}</tbody></table></div>`;
|
|
105
109
|
}
|
|
106
110
|
|
|
111
|
+
function renderAlerts(alerts) {
|
|
112
|
+
if (!alerts.length) {
|
|
113
|
+
return `<div class="empty-state">No active alerts for this profile.</div>`;
|
|
114
|
+
}
|
|
115
|
+
return `
|
|
116
|
+
<div class="alert-list">
|
|
117
|
+
${alerts
|
|
118
|
+
.map(
|
|
119
|
+
(alert) => `
|
|
120
|
+
<article class="alert-card ${statusClass(alert.severity || "warn")}">
|
|
121
|
+
<div class="alert-header">
|
|
122
|
+
<div>
|
|
123
|
+
<h4>${alert.title}</h4>
|
|
124
|
+
<div class="muted mono">${alert.session || "n/a"} · ${alert.task_kind || "task"} ${alert.task_id || ""}</div>
|
|
125
|
+
</div>
|
|
126
|
+
<span class="badge warn">${alert.kind}</span>
|
|
127
|
+
</div>
|
|
128
|
+
<p>${alert.message}</p>
|
|
129
|
+
<div class="alert-meta">
|
|
130
|
+
<span>${alert.reset_at ? `Reset: ${alert.reset_at}` : "Reset: n/a"}</span>
|
|
131
|
+
<span>${alert.updated_at ? `${relativeTime(alert.updated_at)} · ${alert.updated_at}` : "updated n/a"}</span>
|
|
132
|
+
</div>
|
|
133
|
+
</article>
|
|
134
|
+
`,
|
|
135
|
+
)
|
|
136
|
+
.join("")}
|
|
137
|
+
</div>
|
|
138
|
+
`;
|
|
139
|
+
}
|
|
140
|
+
|
|
107
141
|
function renderProfile(profile) {
|
|
108
142
|
const providerBadges = [
|
|
109
143
|
profile.coding_worker ? `<span class="badge good">${profile.coding_worker}</span>` : "",
|
|
@@ -125,6 +159,7 @@ function renderProfile(profile) {
|
|
|
125
159
|
["Live controllers", profile.counts.live_resident_controllers],
|
|
126
160
|
["Stale controllers", profile.counts.stale_resident_controllers],
|
|
127
161
|
["Provider cooldowns", profile.counts.provider_cooldowns],
|
|
162
|
+
["Alerts", profile.counts.alerts || 0],
|
|
128
163
|
["Issue retries", profile.counts.active_retries],
|
|
129
164
|
["Queued issues", profile.counts.queued_issues],
|
|
130
165
|
["Scheduled", profile.counts.scheduled_issues],
|
|
@@ -240,6 +275,11 @@ function renderProfile(profile) {
|
|
|
240
275
|
</header>
|
|
241
276
|
<section class="overview">${summaryCards}</section>
|
|
242
277
|
<section class="profile-grid">
|
|
278
|
+
<section class="panel">
|
|
279
|
+
<h3>Host Alerts</h3>
|
|
280
|
+
<p class="panel-subtitle">High-signal operational blockers surfaced from active run logs and comment artifacts.</p>
|
|
281
|
+
${renderAlerts(profile.alerts || [])}
|
|
282
|
+
</section>
|
|
243
283
|
<section class="panel">
|
|
244
284
|
<h3>Active Runs</h3>
|
|
245
285
|
<p class="panel-subtitle">Lifecycle shows technical session completion. Result shows what the run achieved: implemented, reported, or blocked.</p>
|
|
@@ -275,6 +315,35 @@ function renderProfile(profile) {
|
|
|
275
315
|
`;
|
|
276
316
|
}
|
|
277
317
|
|
|
318
|
+
async function maybeNotifyAlerts(snapshot) {
|
|
319
|
+
const alerts = (snapshot.alerts || []).filter((alert) => alert && alert.id);
|
|
320
|
+
if (!alerts.length || typeof window.Notification === "undefined") return;
|
|
321
|
+
|
|
322
|
+
if (window.Notification.permission === "default" && !notificationPermissionRequested) {
|
|
323
|
+
notificationPermissionRequested = true;
|
|
324
|
+
try {
|
|
325
|
+
await window.Notification.requestPermission();
|
|
326
|
+
} catch (_error) {
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
if (window.Notification.permission !== "granted") return;
|
|
332
|
+
|
|
333
|
+
for (const alert of alerts) {
|
|
334
|
+
if (seenAlertIds.has(alert.id)) continue;
|
|
335
|
+
seenAlertIds.add(alert.id);
|
|
336
|
+
const bodyParts = [];
|
|
337
|
+
if (alert.session) bodyParts.push(alert.session);
|
|
338
|
+
if (alert.reset_at) bodyParts.push(`reset ${alert.reset_at}`);
|
|
339
|
+
if (alert.message) bodyParts.push(alert.message);
|
|
340
|
+
new window.Notification(alert.title || "ACP alert", {
|
|
341
|
+
body: bodyParts.join(" · ").slice(0, 240),
|
|
342
|
+
tag: alert.id,
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
278
347
|
async function loadSnapshot() {
|
|
279
348
|
refreshButton.disabled = true;
|
|
280
349
|
try {
|
|
@@ -286,6 +355,7 @@ async function loadSnapshot() {
|
|
|
286
355
|
generatedAtNode.textContent = `Snapshot: ${snapshot.generated_at}`;
|
|
287
356
|
renderOverview(snapshot);
|
|
288
357
|
profilesNode.innerHTML = snapshot.profiles.map(renderProfile).join("");
|
|
358
|
+
await maybeNotifyAlerts(snapshot);
|
|
289
359
|
} catch (error) {
|
|
290
360
|
generatedAtNode.textContent = `Snapshot load failed: ${error.message}`;
|
|
291
361
|
profilesNode.innerHTML = `<article class="profile"><div class="empty-state">${error.message}</div></article>`;
|
|
@@ -143,6 +143,19 @@ def file_mtime_iso(path: Path) -> str:
|
|
|
143
143
|
return datetime.fromtimestamp(path.stat().st_mtime, tz=timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
144
144
|
|
|
145
145
|
|
|
146
|
+
def read_tail_text(path: Path, max_bytes: int = 65536) -> str:
|
|
147
|
+
if not path.is_file():
|
|
148
|
+
return ""
|
|
149
|
+
try:
|
|
150
|
+
with path.open("rb") as handle:
|
|
151
|
+
size = path.stat().st_size
|
|
152
|
+
if size > max_bytes:
|
|
153
|
+
handle.seek(size - max_bytes)
|
|
154
|
+
return handle.read().decode("utf-8", errors="replace")
|
|
155
|
+
except OSError:
|
|
156
|
+
return ""
|
|
157
|
+
|
|
158
|
+
|
|
146
159
|
def classify_run_result(status: str, outcome: str, failure_reason: str) -> tuple[str, str]:
|
|
147
160
|
normalized_status = (status or "").strip().upper()
|
|
148
161
|
normalized_outcome = (outcome or "").strip()
|
|
@@ -167,6 +180,59 @@ def classify_run_result(status: str, outcome: str, failure_reason: str) -> tuple
|
|
|
167
180
|
return ("unknown", normalized_status or "Unknown")
|
|
168
181
|
|
|
169
182
|
|
|
183
|
+
GITHUB_RATE_LIMIT_PATTERNS = [
|
|
184
|
+
re.compile(
|
|
185
|
+
r"GitHub core API[^\n]*?rate limit[^\n]*(?:reset(?:s| into)?(?: at)?\s+(?P<reset>[^.\n]+))?",
|
|
186
|
+
re.IGNORECASE,
|
|
187
|
+
),
|
|
188
|
+
re.compile(
|
|
189
|
+
r"gh:\s*API rate limit exceeded[^\n]*(?:reset(?:s| into)?(?: at)?\s+(?P<reset>[^.\n]+))?",
|
|
190
|
+
re.IGNORECASE,
|
|
191
|
+
),
|
|
192
|
+
]
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def summarize_whitespace(text: str) -> str:
|
|
196
|
+
return re.sub(r"\s+", " ", text).strip()
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def extract_github_rate_limit_alert(run_dir: Path, run: dict[str, Any]) -> dict[str, Any] | None:
|
|
200
|
+
candidate_files = [
|
|
201
|
+
run_dir / "issue-comment.md",
|
|
202
|
+
run_dir / "pr-comment.md",
|
|
203
|
+
run_dir / f"{run['session']}.log",
|
|
204
|
+
]
|
|
205
|
+
for path in candidate_files:
|
|
206
|
+
text = read_tail_text(path)
|
|
207
|
+
if not text:
|
|
208
|
+
continue
|
|
209
|
+
for pattern in GITHUB_RATE_LIMIT_PATTERNS:
|
|
210
|
+
match = pattern.search(text)
|
|
211
|
+
if not match:
|
|
212
|
+
continue
|
|
213
|
+
summary = summarize_whitespace(match.group(0))
|
|
214
|
+
reset_match = re.search(r"reset(?:s| into)?(?: at)?\s+([^.\n]+)", summary, re.IGNORECASE)
|
|
215
|
+
reset_at = summarize_whitespace((reset_match.group(1) if reset_match else "") or match.groupdict().get("reset") or "")
|
|
216
|
+
if not summary:
|
|
217
|
+
summary = "GitHub core API rate limit is blocking host-side actions."
|
|
218
|
+
if reset_at and reset_at not in summary:
|
|
219
|
+
summary = f"{summary} Reset: {reset_at}."
|
|
220
|
+
return {
|
|
221
|
+
"id": f"github-core-rate-limit:{run['session']}:{reset_at or path.name}",
|
|
222
|
+
"kind": "github-core-rate-limit",
|
|
223
|
+
"severity": "warn",
|
|
224
|
+
"title": "GitHub core API rate limit blocks host actions",
|
|
225
|
+
"message": summary,
|
|
226
|
+
"session": run.get("session", ""),
|
|
227
|
+
"task_kind": run.get("task_kind", ""),
|
|
228
|
+
"task_id": run.get("task_id", ""),
|
|
229
|
+
"reset_at": reset_at,
|
|
230
|
+
"updated_at": run.get("updated_at", "") or file_mtime_iso(path),
|
|
231
|
+
"source_file": str(path),
|
|
232
|
+
}
|
|
233
|
+
return None
|
|
234
|
+
|
|
235
|
+
|
|
170
236
|
def collect_runs(runs_root: Path) -> list[dict[str, Any]]:
|
|
171
237
|
if not runs_root.is_dir():
|
|
172
238
|
return []
|
|
@@ -221,6 +287,8 @@ def collect_runs(runs_root: Path) -> list[dict[str, Any]]:
|
|
|
221
287
|
"provider_pool_name": run_env.get("ACTIVE_PROVIDER_POOL_NAME", ""),
|
|
222
288
|
"run_dir": str(run_dir),
|
|
223
289
|
}
|
|
290
|
+
alert = extract_github_rate_limit_alert(run_dir, item)
|
|
291
|
+
item["alerts"] = [alert] if alert else []
|
|
224
292
|
runs.append(item)
|
|
225
293
|
return runs
|
|
226
294
|
|
|
@@ -430,6 +498,7 @@ def build_profile_snapshot(profile_id: str, registry_root: Path) -> dict[str, An
|
|
|
430
498
|
scheduled = collect_scheduled_issues(state_root)
|
|
431
499
|
retries = collect_issue_retries(state_root)
|
|
432
500
|
queue = collect_issue_queue(state_root)
|
|
501
|
+
alerts = [alert for run in runs for alert in run.get("alerts", [])]
|
|
433
502
|
|
|
434
503
|
return {
|
|
435
504
|
"id": profile_id,
|
|
@@ -471,8 +540,10 @@ def build_profile_snapshot(profile_id: str, registry_root: Path) -> dict[str, An
|
|
|
471
540
|
"provider_cooldowns": sum(1 for item in cooldowns if item["active"]),
|
|
472
541
|
"active_retries": sum(1 for item in retries if not item.get("ready", True)),
|
|
473
542
|
"scheduled_issues": len(scheduled),
|
|
543
|
+
"alerts": len(alerts),
|
|
474
544
|
},
|
|
475
545
|
"runs": runs,
|
|
546
|
+
"alerts": alerts,
|
|
476
547
|
"resident_controllers": controllers,
|
|
477
548
|
"resident_workers": resident_workers,
|
|
478
549
|
"provider_cooldowns": cooldowns,
|
|
@@ -485,11 +556,14 @@ def build_profile_snapshot(profile_id: str, registry_root: Path) -> dict[str, An
|
|
|
485
556
|
def build_snapshot() -> dict[str, Any]:
|
|
486
557
|
registry_root = profile_registry_root()
|
|
487
558
|
profiles = [build_profile_snapshot(profile_id, registry_root) for profile_id in list_profile_ids(registry_root)]
|
|
559
|
+
alerts = [alert for profile in profiles for alert in profile.get("alerts", [])]
|
|
488
560
|
return {
|
|
489
561
|
"generated_at": utc_now_iso(),
|
|
490
562
|
"flow_skill_dir": str(ROOT_DIR),
|
|
491
563
|
"profile_registry_root": str(registry_root),
|
|
492
564
|
"profile_count": len(profiles),
|
|
565
|
+
"alert_count": len(alerts),
|
|
566
|
+
"alerts": alerts,
|
|
493
567
|
"profiles": profiles,
|
|
494
568
|
}
|
|
495
569
|
|
|
@@ -230,6 +230,49 @@ button:hover {
|
|
|
230
230
|
font-size: 14px;
|
|
231
231
|
}
|
|
232
232
|
|
|
233
|
+
.alert-list {
|
|
234
|
+
display: grid;
|
|
235
|
+
gap: 12px;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
.alert-card {
|
|
239
|
+
padding: 14px 16px;
|
|
240
|
+
border-radius: 18px;
|
|
241
|
+
border: 1px solid var(--line);
|
|
242
|
+
background: var(--panel-strong);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
.alert-card.warn {
|
|
246
|
+
border-color: #e7c76d;
|
|
247
|
+
background: #fff6dd;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
.alert-card h4 {
|
|
251
|
+
margin: 0;
|
|
252
|
+
font-size: 17px;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
.alert-card p {
|
|
256
|
+
margin: 10px 0 0;
|
|
257
|
+
line-height: 1.5;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
.alert-header {
|
|
261
|
+
display: flex;
|
|
262
|
+
gap: 12px;
|
|
263
|
+
justify-content: space-between;
|
|
264
|
+
align-items: flex-start;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
.alert-meta {
|
|
268
|
+
margin-top: 10px;
|
|
269
|
+
display: flex;
|
|
270
|
+
flex-wrap: wrap;
|
|
271
|
+
gap: 12px;
|
|
272
|
+
color: var(--muted);
|
|
273
|
+
font-size: 13px;
|
|
274
|
+
}
|
|
275
|
+
|
|
233
276
|
.table-wrap {
|
|
234
277
|
overflow-x: auto;
|
|
235
278
|
}
|