pi-crew 0.11.1 → 0.11.4
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/CHANGELOG.md +82 -9
- package/README.md +161 -1036
- package/agents/verifier.md +18 -7
- package/dist/index.mjs +742 -91462
- package/docs/README.md +57 -46
- package/docs/architecture.md +87 -33
- package/docs/commands-reference.md +9 -5
- package/docs/troubleshooting.md +3 -2
- package/package.json +5 -4
- package/schema.json +29 -0
- package/skills/real-test-pi-crew/SKILL.md +206 -41
- package/src/agents/agent-config.ts +1 -1
- package/src/agents/discover-agents.ts +1 -1
- package/src/config/config-validation.ts +15 -0
- package/src/config/config.ts +47 -13
- package/src/config/env-vars.ts +35 -0
- package/src/config/types.ts +19 -0
- package/src/errors.ts +2 -2
- package/src/extension/async-notifier.ts +55 -8
- package/src/extension/crew-input-router.ts +9 -2
- package/src/extension/help.ts +21 -10
- package/src/extension/knowledge-injection.ts +2 -1
- package/src/extension/management.ts +8 -3
- package/src/extension/notification-sink.ts +17 -0
- package/src/extension/plan-orchestrate.ts +19 -3
- package/src/extension/registration/command-utils.ts +28 -2
- package/src/extension/registration/commands/dashboard.ts +11 -1
- package/src/extension/registration/commands/manage.ts +31 -15
- package/src/extension/registration/commands/run.ts +24 -2
- package/src/extension/registration/commands/shared.ts +34 -10
- package/src/extension/registration/commands/status.ts +25 -2
- package/src/extension/registration/context-builder.ts +8 -2
- package/src/extension/registration/foreground-run-controller.ts +2 -2
- package/src/extension/registration/health-notify-policy.ts +100 -0
- package/src/extension/registration/lazy-configurers.ts +38 -0
- package/src/extension/registration/lifecycle-handlers.ts +107 -40
- package/src/extension/registration/lifecycle.ts +77 -10
- package/src/extension/registration/observability.ts +125 -53
- package/src/extension/registration/registration-types.ts +7 -5
- package/src/extension/registration/runtime-cleanup.ts +9 -3
- package/src/extension/registration/subagent-helpers.ts +38 -0
- package/src/extension/registration/team-tool.ts +9 -2
- package/src/extension/registration/wire-cross-extension.ts +30 -0
- package/src/extension/run-compare.ts +220 -0
- package/src/extension/run-export.ts +37 -5
- package/src/extension/run-maintenance.ts +155 -5
- package/src/extension/team-manager-command.ts +3 -2
- package/src/extension/team-tool/dispatch/index.ts +3 -2
- package/src/extension/team-tool/dispatch/manage.ts +5 -2
- package/src/extension/team-tool/goal-wrap.ts +7 -0
- package/src/extension/team-tool/goal.ts +16 -1
- package/src/extension/team-tool/handle-settings.ts +19 -2
- package/src/extension/team-tool/health-monitor.ts +21 -7
- package/src/extension/team-tool/lifecycle-actions.ts +49 -1
- package/src/extension/team-tool/parallel-dispatch.ts +16 -1
- package/src/extension/team-tool/plan.ts +10 -0
- package/src/extension/team-tool/routing-hint.ts +63 -0
- package/src/extension/team-tool/run-intent.ts +1 -1
- package/src/extension/team-tool/run.ts +1 -1
- package/src/extension/team-tool/status.ts +4 -0
- package/src/extension/team-tool.ts +52 -6
- package/src/extension/webhook-notify.ts +382 -0
- package/src/observability/metric-sink.ts +12 -2
- package/src/prompt/prompt-runtime.ts +82 -31
- package/src/prompt/worker-events-channel.ts +12 -0
- package/src/runtime/README.md +1 -1
- package/src/runtime/async-runner.ts +123 -1
- package/src/runtime/background-runner.ts +313 -234
- package/src/runtime/broker/crew-broker.ts +76 -71
- package/src/runtime/broker/delegate/delegate-event.ts +12 -8
- package/src/runtime/broker/delegate/shadow-lifecycle.ts +92 -0
- package/src/runtime/broker/protocol/connection-state.ts +10 -0
- package/src/runtime/broker/protocol/lock-busy.ts +76 -0
- package/src/runtime/broker/wait-status-cache.ts +1 -1
- package/src/runtime/child-pi/child-pi-timers.ts +1 -1
- package/src/runtime/child-pi/mock-fixtures.ts +48 -0
- package/src/runtime/crew-agent-records.ts +337 -45
- package/src/runtime/deadletter.ts +43 -1
- package/src/runtime/delegate-spawn.ts +21 -3
- package/src/runtime/deterministic-ast.ts +14 -2
- package/src/runtime/dispatch-batch.ts +72 -5
- package/src/runtime/goal-workflow/dynamic-workflow-context.ts +78 -58
- package/src/runtime/goal-workflow/dynamic-workflow-runner.ts +14 -2
- package/src/runtime/goal-workflow/goal-loop-runner.ts +105 -4
- package/src/runtime/group-join.ts +11 -3
- package/src/runtime/heartbeat/heartbeat-watcher.ts +7 -0
- package/src/runtime/live-session/live-session-runtime.ts +1 -1
- package/src/runtime/model/model-fallback.ts +21 -1
- package/src/runtime/model/pi-args.ts +8 -10
- package/src/runtime/model/runtime-warmup.ts +2 -0
- package/src/runtime/output/output-validator.ts +10 -5
- package/src/runtime/plan-approval.ts +14 -1
- package/src/runtime/recovery/crash-recovery.ts +96 -49
- package/src/runtime/run-worker.ts +12 -1
- package/src/runtime/scheduling/global-worker-cap.ts +13 -6
- package/src/runtime/scheduling/run-coalesced-task-group.ts +27 -1
- package/src/runtime/scheduling/scheduler.ts +49 -13
- package/src/runtime/scheduling/semaphore.ts +148 -20
- package/src/runtime/scratchpad/README.md +1 -1
- package/src/runtime/scratchpad/protocol.ts +1 -1
- package/src/runtime/settings-store.ts +1 -1
- package/src/runtime/skill-instructions.ts +22 -0
- package/src/runtime/stale-reconciler.ts +85 -13
- package/src/runtime/subagent-manager.ts +43 -13
- package/src/runtime/task-runner/child-executor.ts +17 -6
- package/src/runtime/task-runner/post-execution.ts +44 -14
- package/src/runtime/task-runner/pre-execution.ts +26 -2
- package/src/runtime/task-runner/prompt-builder.ts +150 -45
- package/src/runtime/task-runner/retrieval-orchestrator.ts +36 -14
- package/src/runtime/task-runner.ts +28 -1
- package/src/runtime/team-runner.ts +42 -2
- package/src/runtime/verification/verification-worktree.ts +3 -0
- package/src/runtime/workspace-lock.ts +4 -1
- package/src/schema/config-schema.ts +14 -0
- package/src/schema/team-tool-schema.ts +17 -0
- package/src/state/atomic-write.ts +53 -0
- package/src/state/contracts.ts +109 -0
- package/src/state/coordination/locks.ts +191 -33
- package/src/state/coordination/mailbox.ts +147 -17
- package/src/state/crew-init.ts +87 -12
- package/src/state/event-log/cursor.ts +37 -1
- package/src/state/event-log/event-log-rotation.ts +72 -7
- package/src/state/stores/active-run-registry.ts +13 -1
- package/src/state/stores/state-store.ts +120 -23
- package/src/state/types.ts +4 -0
- package/src/ui/dashboard-panes/agents-pane.ts +22 -10
- package/src/ui/heartbeat-aggregator.ts +34 -0
- package/src/ui/inline-panel/index.ts +1 -1
- package/src/ui/keybinding-map.ts +22 -4
- package/src/ui/live-conversation-overlay.ts +6 -3
- package/src/ui/render-diff.ts +19 -6
- package/src/ui/run-dashboard.ts +109 -6
- package/src/ui/run-snapshot-cache.ts +27 -4
- package/src/ui/spinner.ts +26 -2
- package/src/ui/tool-progress-formatter.ts +2 -1
- package/src/ui/tool-renderers/brief-mode.ts +2 -1
- package/src/ui/tool-renderers/index.ts +3 -3
- package/src/ui/widget/task-list.ts +8 -5
- package/src/ui/widget/widget-model.ts +3 -1
- package/src/utils/incremental-reader.ts +11 -3
- package/src/utils/paths.ts +94 -12
- package/src/utils/project-markers.ts +40 -0
- package/src/worktree/worktree-manager.ts +206 -26
- package/tsconfig.json +2 -0
- package/workflows/distill.workflow.md +3 -3
- package/workflows/fast-fix.workflow.md +1 -1
- package/workflows/plan-execute.workflow.md +1 -1
- package/workflows/review.workflow.md +1 -1
- package/workflows/strict-fast-fix.workflow.md +1 -1
- package/docs/migration-v0.4-v0.5.md +0 -208
- package/docs/runtime-flow.md +0 -148
- package/src/ui/card-colors.ts +0 -168
- package/src/utils/completion-dedupe.ts +0 -63
- package/src/utils/resolve-shell.ts +0 -36
|
@@ -13,12 +13,14 @@ import type { CrewReliabilityConfig } from "../config/config.ts";
|
|
|
13
13
|
import { CrewError, ErrorCode } from "../errors.ts";
|
|
14
14
|
import { appendHookEvent, executeHook } from "../hooks/registry.ts";
|
|
15
15
|
import { childCorrelation, withCorrelation } from "../observability/correlation.ts";
|
|
16
|
+
import { TEAM_TERMINAL_RUN_STATUSES } from "../state/contracts.ts";
|
|
16
17
|
import { withRunLockSync } from "../state/coordination/locks.ts";
|
|
17
18
|
import { appendEventAsync, appendEventBuffered } from "../state/event-log/event-log.ts";
|
|
18
19
|
import { loadRunManifestById, saveRunManifest, saveRunTasks, saveRunTasksAsync, updateRunStatus } from "../state/stores/state-store.ts";
|
|
19
20
|
import type { TaskAttemptState, TeamRunManifest, TeamTaskState } from "../state/types.ts";
|
|
20
21
|
import { logInternalError } from "../utils/internal-error.ts";
|
|
21
22
|
import type { WorkflowConfig, WorkflowStep } from "../workflows/workflow-config.ts";
|
|
23
|
+
import { isDelegateShadowTask } from "./broker/delegate/shadow-lifecycle.ts";
|
|
22
24
|
import { readCrewAgents, recordFromTask, saveCrewAgents } from "./crew-agent-records.ts";
|
|
23
25
|
import { appendDeadletter } from "./deadletter.ts";
|
|
24
26
|
import { classifyHeartbeat, DEFAULT_GRADIENT_THRESHOLDS } from "./heartbeat/heartbeat-gradient.ts";
|
|
@@ -316,6 +318,26 @@ export function shouldUseRetry(reliability: CrewReliabilityConfig | undefined):
|
|
|
316
318
|
return reliability?.autoRetry !== false;
|
|
317
319
|
}
|
|
318
320
|
|
|
321
|
+
/**
|
|
322
|
+
* T9d cancel-race fix (2026-09-23, live battery finding 7): a retry attempt must
|
|
323
|
+
* re-queue ONLY the task's OWN terminal failure (status "failed") while the run is
|
|
324
|
+
* still active. Previously ANY non-queued/running status was re-queued — including
|
|
325
|
+
* "cancelled", so a cross-session cancel landing between attempt 1's hard-kill and
|
|
326
|
+
* attempt 2's start (measured live: cancel 16:46:22.987 → task.started 16:46:23.948,
|
|
327
|
+
* replacement worker then ran 47s post-cancel) silently resurrected the task.
|
|
328
|
+
* External terminal decisions (task cancelled/completed, or a terminal RUN manifest)
|
|
329
|
+
* are respected. Exported for unit testing.
|
|
330
|
+
*/
|
|
331
|
+
export function shouldRequeueForRetry(input: {
|
|
332
|
+
attempt: number;
|
|
333
|
+
taskStatus: TeamTaskState["status"];
|
|
334
|
+
manifestStatus: TeamRunManifest["status"];
|
|
335
|
+
}): boolean {
|
|
336
|
+
if (input.attempt <= 1) return false;
|
|
337
|
+
if (TEAM_TERMINAL_RUN_STATUSES.has(input.manifestStatus)) return false;
|
|
338
|
+
return input.taskStatus === "failed";
|
|
339
|
+
}
|
|
340
|
+
|
|
319
341
|
function failedTaskFrom(result: { tasks: TeamTaskState[] }, taskId: string): TeamTaskState | undefined {
|
|
320
342
|
return result.tasks.find((item) => item.id === taskId && item.status === "failed");
|
|
321
343
|
}
|
|
@@ -328,6 +350,13 @@ function failedTaskFrom(result: { tasks: TeamTaskState[] }, taskId: string): Tea
|
|
|
328
350
|
function dagReadyTaskIds(tasks: TeamTaskState[], completedIds: Set<string>): string[] | null {
|
|
329
351
|
const hasExplicitDeps = tasks.some((t) => t.dependsOn.length > 0);
|
|
330
352
|
if (!hasExplicitDeps) return null;
|
|
353
|
+
// RR-012 (adjacent scheduler risk — proven reachable): exclude
|
|
354
|
+
// delegate-broker shadow records from the DAG. They are managed by the
|
|
355
|
+
// external grandchild spawner, not this scheduler: (1) as WAVE members a
|
|
356
|
+
// running shadow would gate dependent waves / trigger markBlocked although
|
|
357
|
+
// it is never in ctx.pendingUnits; (2) getDagReadyTasks below ignores both
|
|
358
|
+
// status AND graph, so a queued OR running shadow surfaces as ready.
|
|
359
|
+
const workflowTasks = tasks.filter((t) => !isDelegateShadowTask(t));
|
|
331
360
|
// FIX (goal-wrap runtime test): task.dependsOn stores STEP IDs (e.g. "execute"), not
|
|
332
361
|
// task IDs (e.g. "02_execute"). The DAG scheduler compares deps against completedIds
|
|
333
362
|
// (which are task IDs), so step-ID deps would never match → dependent tasks stuck blocked
|
|
@@ -335,10 +364,10 @@ function dagReadyTaskIds(tasks: TeamTaskState[], completedIds: Set<string>): str
|
|
|
335
364
|
// task-graph-scheduler.ts which handles this via stepToTaskId). buildDagExecutionPlan +
|
|
336
365
|
// getDagReadyTasks then work on consistent task IDs.
|
|
337
366
|
const stepToTaskId = new Map<string, string>();
|
|
338
|
-
for (const t of
|
|
367
|
+
for (const t of workflowTasks) {
|
|
339
368
|
if (t.stepId) stepToTaskId.set(t.stepId, t.id);
|
|
340
369
|
}
|
|
341
|
-
const nodes: TaskNode[] =
|
|
370
|
+
const nodes: TaskNode[] = workflowTasks.map((t) => ({
|
|
342
371
|
id: t.id,
|
|
343
372
|
dependsOn: t.dependsOn.map((dep) => stepToTaskId.get(dep) ?? dep),
|
|
344
373
|
phase: t.adaptive?.phase ?? t.stepId,
|
|
@@ -410,7 +439,15 @@ export async function selectDispatchBatch(ctx: SchedulerContext): Promise<Schedu
|
|
|
410
439
|
// existing task-graph-scheduler when no explicit deps exist (backward compat).
|
|
411
440
|
const completedIds = new Set(ctx.tasks.filter((t) => t.status === "completed" || t.status === "needs_attention").map((t) => t.id));
|
|
412
441
|
const dagReady = dagReadyTaskIds(ctx.tasks, completedIds);
|
|
413
|
-
|
|
442
|
+
// RR-012 (adjacent scheduler risk — proven reachable): belt-and-braces
|
|
443
|
+
// selection guard. dagReadyTaskIds already excludes shadow records from the
|
|
444
|
+
// DAG; this filter also covers the snapshot fallback (a graphed queued
|
|
445
|
+
// shadow would resolve to queue "ready"). A shadow in a batch makes
|
|
446
|
+
// findStep() throw ResourceNotFound (task.stepId === undefined) — the run
|
|
447
|
+
// aborts (pre-warm site) or the record is force-failed by the synthesized
|
|
448
|
+
// unit error. Shadow records stay visible in snapshots / team status.
|
|
449
|
+
const shadowTaskIds = new Set(ctx.tasks.filter(isDelegateShadowTask).map((t) => t.id));
|
|
450
|
+
const readyBeforeFilter = (dagReady ?? snapshot.ready).filter((taskId) => !shadowTaskIds.has(taskId));
|
|
414
451
|
|
|
415
452
|
// Workflow phase precondition check (non-blocking: log warnings only).
|
|
416
453
|
if (ctx.wfMachine.currentPhaseIndex < ctx.wfMachine.phases.length) {
|
|
@@ -777,8 +814,25 @@ export async function dispatchBatch(ctx: SchedulerContext, decision: DispatchBat
|
|
|
777
814
|
// NOTE: no withRunLock — best-effort only; concurrent writes may cause inconsistency
|
|
778
815
|
const fresh = loadRunManifestById(ctx.manifest.cwd, ctx.manifest.runId);
|
|
779
816
|
const freshManifest = fresh?.manifest ?? ctx.manifest;
|
|
780
|
-
|
|
781
|
-
|
|
817
|
+
let freshTasks = fresh?.tasks ?? ctx.tasks;
|
|
818
|
+
let freshTask = freshTasks.find((item) => item.id === task.id) ?? task;
|
|
819
|
+
// US-003 (2026-09-22) — RETRY-NOOP BUG, found while wiring the dead-letter
|
|
820
|
+
// integration test: a retry (attempt > 1) only happens because OUR previous
|
|
821
|
+
// attempt THREW — i.e. we ourselves persisted the terminal failure (e.g.
|
|
822
|
+
// model-exhausted marks the task failed before the throw). The terminal-state
|
|
823
|
+
// early-return below then treated OUR OWN failure as "nothing to do" and
|
|
824
|
+
// returned it as SUCCESS: executeWithRetry never re-ran the task, never
|
|
825
|
+
// exhausted, and the onRetryGivenUp dead-letter never fired — autoRetry was
|
|
826
|
+
// silently a single attempt for every failure that persists task state.
|
|
827
|
+
// Fix: on a retry, re-queue OUR OWN terminal failure so the retry actually
|
|
828
|
+
// re-runs. Attempt 1 keeps the original guard (externally-terminal task =
|
|
829
|
+
// someone else's decision; external cancellation exits via the signal).
|
|
830
|
+
// 2026-09-23 (finding 7): re-queue ONLY own failures on an ACTIVE run —
|
|
831
|
+
// cancelled/completed tasks and terminal manifests must never resurrect.
|
|
832
|
+
if (shouldRequeueForRetry({ attempt, taskStatus: freshTask.status, manifestStatus: freshManifest.status })) {
|
|
833
|
+
freshTask = { ...freshTask, status: "queued", error: undefined, finishedAt: undefined };
|
|
834
|
+
freshTasks = freshTasks.map((item) => (item.id === task.id ? freshTask : item));
|
|
835
|
+
}
|
|
782
836
|
if (freshTask.status !== "queued" && freshTask.status !== "running")
|
|
783
837
|
return {
|
|
784
838
|
manifest: freshManifest,
|
|
@@ -855,6 +909,15 @@ export async function dispatchBatch(ctx: SchedulerContext, decision: DispatchBat
|
|
|
855
909
|
},
|
|
856
910
|
onRetryGivenUp: (attempts, error, info) => {
|
|
857
911
|
lastAttemptId = info.attemptId;
|
|
912
|
+
// US-003: aborts are NOT exhaustion — executeWithRetry also fires this
|
|
913
|
+
// hook on its cancelled exit, which previously deadlettered cancelled
|
|
914
|
+
// tasks as "max-retries" (false positive; keep the signal high).
|
|
915
|
+
if (runController.signal.aborted) return;
|
|
916
|
+
// US-003: richer entry (agent/role/modelAttempts/runStatus) so the
|
|
917
|
+
// project-level index is self-sufficient after the run dir is pruned.
|
|
918
|
+
// Prefer the FAILED attempt's final task state over the pre-run snapshot.
|
|
919
|
+
const failedTask = lastFailed?.tasks.find((item) => item.id === task.id);
|
|
920
|
+
const deadletterTask = failedTask ?? task;
|
|
858
921
|
appendDeadletter(ctx.manifest, {
|
|
859
922
|
runId: ctx.manifest.runId,
|
|
860
923
|
taskId: task.id,
|
|
@@ -863,6 +926,10 @@ export async function dispatchBatch(ctx: SchedulerContext, decision: DispatchBat
|
|
|
863
926
|
attemptId: info.attemptId,
|
|
864
927
|
lastError: error.message,
|
|
865
928
|
timestamp: new Date().toISOString(),
|
|
929
|
+
agent: deadletterTask.agent,
|
|
930
|
+
role: deadletterTask.role,
|
|
931
|
+
modelAttempts: deadletterTask.modelAttempts?.length,
|
|
932
|
+
runStatus: ctx.manifest.status,
|
|
866
933
|
});
|
|
867
934
|
input.metricRegistry
|
|
868
935
|
?.counter("crew.task.deadletter_total", "Deadletter triggers by reason")
|
|
@@ -906,67 +906,87 @@ function describeSchemaShape(schema: unknown, depth: number): string {
|
|
|
906
906
|
* reject is checked first because a review can mention both "correctly" (describing existing code)
|
|
907
907
|
* AND "critical bug" (the verdict) — the verdict signal must win.
|
|
908
908
|
*/
|
|
909
|
+
// Precompiled review-outcome signals (module scope — compiled once, not per call).
|
|
910
|
+
// RR-021 WI-1.1: these were previously string literals like "\baccept\b" compiled
|
|
911
|
+
// per call — and "\b" in a TS string literal is a BACKSPACE character (U+0008),
|
|
912
|
+
// so those two signals never matched anything. Regex literals here give real word
|
|
913
|
+
// boundaries. No /g flags → no shared lastIndex state across .test() calls.
|
|
914
|
+
const REJECT_SIGNAL_REGEXES: readonly RegExp[] = [
|
|
915
|
+
/\breject\b/,
|
|
916
|
+
/fundamentally/,
|
|
917
|
+
/completely broken/,
|
|
918
|
+
/totally broken/,
|
|
919
|
+
/critical bug/,
|
|
920
|
+
/critical issue/,
|
|
921
|
+
/critical flaw/,
|
|
922
|
+
/security vulnerability/,
|
|
923
|
+
/does not work/,
|
|
924
|
+
/doesn't work/,
|
|
925
|
+
/will not work/,
|
|
926
|
+
/fails to/,
|
|
927
|
+
/unacceptable/,
|
|
928
|
+
/must not be merged/,
|
|
929
|
+
/do not merge/,
|
|
930
|
+
/wrong approach/,
|
|
931
|
+
/logically incorrect/,
|
|
932
|
+
/incorrectly implements/,
|
|
933
|
+
/returns the opposite/,
|
|
934
|
+
/subtraction instead of addition/,
|
|
935
|
+
/opposite of its intended/,
|
|
936
|
+
];
|
|
937
|
+
const ACCEPT_SIGNAL_REGEXES: readonly RegExp[] = [
|
|
938
|
+
/\baccept\b/,
|
|
939
|
+
/looks good/,
|
|
940
|
+
/well done/,
|
|
941
|
+
/no issues/,
|
|
942
|
+
/no real issues/,
|
|
943
|
+
/no problems/,
|
|
944
|
+
/no concerns/,
|
|
945
|
+
/nothing to change/,
|
|
946
|
+
/ready to merge/,
|
|
947
|
+
/lgtm/,
|
|
948
|
+
/ship it/,
|
|
949
|
+
/correctly implements/,
|
|
950
|
+
/correctly returns/,
|
|
951
|
+
/works as expected/,
|
|
952
|
+
/works correctly/,
|
|
953
|
+
/no bugs/,
|
|
954
|
+
/no defects/,
|
|
955
|
+
/meets all requirements/,
|
|
956
|
+
/all requirements met/,
|
|
957
|
+
/passes all/,
|
|
958
|
+
/is correct/,
|
|
959
|
+
/are correct/,
|
|
960
|
+
/no changes needed/,
|
|
961
|
+
/no changes required/,
|
|
962
|
+
/no further changes/,
|
|
963
|
+
/nothing more to/,
|
|
964
|
+
/complete and correct/,
|
|
965
|
+
/sound implementation/,
|
|
966
|
+
];
|
|
967
|
+
// RR-021 WI-1.1 negation guard: phrases that NEGATE the bare "accept" signal.
|
|
968
|
+
// "I cannot accept this" is a complaint, not approval — the accept hit must be
|
|
969
|
+
// discarded so the outcome falls through to changes_requested (or reject if a
|
|
970
|
+
// reject signal is also present).
|
|
971
|
+
const NEGATED_ACCEPT_REGEXES: readonly RegExp[] = [
|
|
972
|
+
/\bcannot\s+accept\b/,
|
|
973
|
+
/\bcan'?t\s+accept\b/,
|
|
974
|
+
/\bdon'?t\s+accept\b/,
|
|
975
|
+
/\bwon'?t\s+accept\b/,
|
|
976
|
+
/\bdo\s+not\s+accept\b/,
|
|
977
|
+
/\bwill\s+not\s+accept\b/,
|
|
978
|
+
/\bunable\s+to\s+accept\b/,
|
|
979
|
+
/\bnot\s+accept(?:ed|ing|able)?\b/,
|
|
980
|
+
];
|
|
981
|
+
|
|
909
982
|
export function classifyReviewOutcome(prose: string): "accept" | "reject" | "changes_requested" {
|
|
910
983
|
const text = prose.toLowerCase();
|
|
911
|
-
//
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
"fundamentally",
|
|
915
|
-
"completely broken",
|
|
916
|
-
"totally broken",
|
|
917
|
-
"critical bug",
|
|
918
|
-
"critical issue",
|
|
919
|
-
"critical flaw",
|
|
920
|
-
"security vulnerability",
|
|
921
|
-
"does not work",
|
|
922
|
-
"doesn't work",
|
|
923
|
-
"will not work",
|
|
924
|
-
"fails to",
|
|
925
|
-
"unacceptable",
|
|
926
|
-
"must not be merged",
|
|
927
|
-
"do not merge",
|
|
928
|
-
"wrong approach",
|
|
929
|
-
"logically incorrect",
|
|
930
|
-
"incorrectly implements",
|
|
931
|
-
"returns the opposite",
|
|
932
|
-
"subtraction instead of addition",
|
|
933
|
-
"opposite of its intended",
|
|
934
|
-
];
|
|
935
|
-
// Acceptance signals → accept. These indicate explicit approval with no real issues.
|
|
936
|
-
const acceptSignals = [
|
|
937
|
-
"\baccept\b",
|
|
938
|
-
"looks good",
|
|
939
|
-
"well done",
|
|
940
|
-
"no issues",
|
|
941
|
-
"no real issues",
|
|
942
|
-
"no problems",
|
|
943
|
-
"no concerns",
|
|
944
|
-
"nothing to change",
|
|
945
|
-
"ready to merge",
|
|
946
|
-
"lgtm",
|
|
947
|
-
"ship it",
|
|
948
|
-
"correctly implements",
|
|
949
|
-
"correctly returns",
|
|
950
|
-
"works as expected",
|
|
951
|
-
"works correctly",
|
|
952
|
-
"no bugs",
|
|
953
|
-
"no defects",
|
|
954
|
-
"meets all requirements",
|
|
955
|
-
"all requirements met",
|
|
956
|
-
"passes all",
|
|
957
|
-
"is correct",
|
|
958
|
-
"are correct",
|
|
959
|
-
"no changes needed",
|
|
960
|
-
"no changes required",
|
|
961
|
-
"no further changes",
|
|
962
|
-
"nothing more to",
|
|
963
|
-
"complete and correct",
|
|
964
|
-
"sound implementation",
|
|
965
|
-
];
|
|
966
|
-
const hasReject = rejectSignals.some((sig) => new RegExp(sig).test(text));
|
|
967
|
-
const hasAccept = acceptSignals.some((sig) => new RegExp(sig).test(text));
|
|
984
|
+
// Reject-first precedence: a review can mention both "correctly" (describing
|
|
985
|
+
// existing code) AND "critical bug" (the verdict) — the verdict signal must win.
|
|
986
|
+
const hasReject = REJECT_SIGNAL_REGEXES.some((re) => re.test(text));
|
|
968
987
|
if (hasReject) return "reject";
|
|
969
|
-
|
|
988
|
+
const hasAccept = ACCEPT_SIGNAL_REGEXES.some((re) => re.test(text));
|
|
989
|
+
if (hasAccept && !NEGATED_ACCEPT_REGEXES.some((re) => re.test(text))) return "accept";
|
|
970
990
|
return "changes_requested";
|
|
971
991
|
}
|
|
972
992
|
|
|
@@ -18,8 +18,8 @@
|
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
20
|
import { readFileSync } from "node:fs";
|
|
21
|
+
import { createRequire } from "node:module";
|
|
21
22
|
import { join } from "node:path";
|
|
22
|
-
import { transformSync } from "esbuild";
|
|
23
23
|
import { getCrewEnv } from "../../config/env-vars.ts";
|
|
24
24
|
import { appendEvent } from "../../state/event-log/event-log.ts";
|
|
25
25
|
import { writeArtifact } from "../../state/stores/artifact-store.ts";
|
|
@@ -33,6 +33,18 @@ import { assertDeterministicScript, isDeterminismCheckEnabled } from "../determi
|
|
|
33
33
|
import { DwfStore } from "../dwf-state-store.ts";
|
|
34
34
|
import { getWorkflowFinalResult, getWorkflowPhaseState, makeWorkflowCtx } from "./dynamic-workflow-context.ts";
|
|
35
35
|
|
|
36
|
+
// RR-021 WI-2.1: esbuild is a heavy native dep (~35ms+ cold load) but is only
|
|
37
|
+
// needed when a .dwf.ts script actually executes. Lazy-load via a synchronous
|
|
38
|
+
// createRequire shim on first use (model: ui/syntax-highlight.ts) — call sites
|
|
39
|
+
// stay sync. esbuild remains external in the bundle and resolves through here.
|
|
40
|
+
const nodeRequire = createRequire(import.meta.url);
|
|
41
|
+
type EsbuildModule = typeof import("esbuild");
|
|
42
|
+
let cachedEsbuild: EsbuildModule | undefined;
|
|
43
|
+
function esbuild(): EsbuildModule {
|
|
44
|
+
if (!cachedEsbuild) cachedEsbuild = nodeRequire("esbuild") as EsbuildModule;
|
|
45
|
+
return cachedEsbuild;
|
|
46
|
+
}
|
|
47
|
+
|
|
36
48
|
export interface RunDynamicWorkflowInput {
|
|
37
49
|
manifest: TeamRunManifest;
|
|
38
50
|
workflow: DynamicWorkflowConfig;
|
|
@@ -119,7 +131,7 @@ async function loadWorkflowModule(scriptPath: string): Promise<DynamicWorkflowSc
|
|
|
119
131
|
// DISC-1: acorn can only parse JavaScript, not TypeScript. Real .dwf.ts files
|
|
120
132
|
// contain type annotations/imports that cause a silent parse error, so the
|
|
121
133
|
// determinism check never runs. Transpile to JS via esbuild first.
|
|
122
|
-
const js = transformSync(scriptSource, { loader: "ts", format: "esm" }).code;
|
|
134
|
+
const js = esbuild().transformSync(scriptSource, { loader: "ts", format: "esm" }).code;
|
|
123
135
|
assertDeterministicScript(js);
|
|
124
136
|
}
|
|
125
137
|
// jiti is the same loader async-runner.ts uses (resolveTypeScriptLoader). We require it
|
|
@@ -23,12 +23,13 @@ import { effectiveRunConfig } from "../../extension/team-tool/config-patch.ts";
|
|
|
23
23
|
import { appendEventBuffered } from "../../state/event-log/event-log.ts";
|
|
24
24
|
import { registerActiveRun, unregisterActiveRun } from "../../state/stores/active-run-registry.ts";
|
|
25
25
|
import { collectRunMetrics } from "../../state/stores/run-metrics.ts";
|
|
26
|
-
import { createRunManifest, saveRunTasks } from "../../state/stores/state-store.ts";
|
|
26
|
+
import { createRunManifest, loadRunManifestById, saveRunTasks, updateRunStatus } from "../../state/stores/state-store.ts";
|
|
27
27
|
import type { GoalLoopState, GoalLoopStatus, GoalVerdict, TeamRunManifest, TeamTaskState } from "../../state/types.ts";
|
|
28
28
|
import type { TeamConfig } from "../../teams/team-config.ts";
|
|
29
29
|
import { logInternalError } from "../../utils/internal-error.ts";
|
|
30
30
|
import type { WorkflowConfig } from "../../workflows/workflow-config.ts";
|
|
31
31
|
import { resolveCrewRuntime } from "../model/runtime-resolver.ts";
|
|
32
|
+
import { registryFromModelContext } from "../model/session-model.ts";
|
|
32
33
|
import { executeTeamRun } from "../team-runner.ts";
|
|
33
34
|
import { compareSnapshot, snapshotManifests } from "../verification/verification-integrity.ts";
|
|
34
35
|
import { acquireWorkspaceLock, type WorkspaceLockHandle } from "../workspace-lock.ts";
|
|
@@ -39,6 +40,17 @@ import { GoalStore } from "./goal-state-store.ts";
|
|
|
39
40
|
export interface GoalLoopRuntimeDeps {
|
|
40
41
|
/** Resolve the agent configs reachable from cwd (used for executeTeamRun's agents arg). */
|
|
41
42
|
discoverAgents: (cwd: string) => AgentConfig[];
|
|
43
|
+
/**
|
|
44
|
+
* SR-01 test seam: override the per-turn config/runtime resolution. When
|
|
45
|
+
* provided, it replaces the loadConfig/effectiveRunConfig/resolveCrewRuntime
|
|
46
|
+
* chain — a test can throw here to exercise the pre-executeTeamRun failure
|
|
47
|
+
* path deterministically (the alternative, monkey-patching modules, is
|
|
48
|
+
* flaky). Production never sets it.
|
|
49
|
+
*/
|
|
50
|
+
resolveTurnConfig?: (cwd: string) => {
|
|
51
|
+
executedConfig: ReturnType<typeof effectiveRunConfig>;
|
|
52
|
+
runtime: Awaited<ReturnType<typeof resolveCrewRuntime>>;
|
|
53
|
+
};
|
|
42
54
|
}
|
|
43
55
|
|
|
44
56
|
export interface RunGoalLoopInput {
|
|
@@ -54,6 +66,29 @@ export interface RunGoalLoopResult {
|
|
|
54
66
|
goalState: GoalLoopState;
|
|
55
67
|
}
|
|
56
68
|
|
|
69
|
+
/**
|
|
70
|
+
* Finding #1 (battery 2026-09-29): re-hydrate the model routing inputs the
|
|
71
|
+
* OUTER goal-loop manifest persisted at dispatch time (captureRunModelContext
|
|
72
|
+
* in goal.ts / goal-wrap.ts). The detached goal-loop runs in the background
|
|
73
|
+
* runner with no ExtensionContext — without this, every turn's executeTeamRun
|
|
74
|
+
* resolves the DEFAULT chain (live: minimax/MiniMax-M3 402 while the parent
|
|
75
|
+
* session ran zai/glm-5.3). Mirrors background-runner's restoredModelRouting().
|
|
76
|
+
*/
|
|
77
|
+
function restoredTurnModelRouting(manifest: TeamRunManifest): {
|
|
78
|
+
modelOverride?: string;
|
|
79
|
+
parentModel?: string;
|
|
80
|
+
modelRegistry?: { getAvailable: () => unknown[] };
|
|
81
|
+
} {
|
|
82
|
+
const context = manifest.modelContext;
|
|
83
|
+
if (!context) return {};
|
|
84
|
+
const modelRegistry = registryFromModelContext(context);
|
|
85
|
+
return {
|
|
86
|
+
...(context.override ? { modelOverride: context.override } : {}),
|
|
87
|
+
...(context.parentModel ? { parentModel: context.parentModel } : {}),
|
|
88
|
+
...(modelRegistry ? { modelRegistry } : {}),
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
57
92
|
/**
|
|
58
93
|
* The placeholder evaluator for P0: always returns {achieved:false}.
|
|
59
94
|
* Kept for unit tests of the loop's max_turns exit path. The production loop
|
|
@@ -619,6 +654,10 @@ export async function runGoalLoop(input: RunGoalLoopInput): Promise<RunGoalLoopR
|
|
|
619
654
|
workspaceMode: "single",
|
|
620
655
|
ownerSessionId: goal.ownerSessionId,
|
|
621
656
|
runKind: "team-run", // §0a v2 note: turns are normal team-runs; the OUTER loop is goal-loop
|
|
657
|
+
// Finding #1: turns execute in the detached process — carry the outer
|
|
658
|
+
// manifest's model routing snapshot onto each turn manifest too, so
|
|
659
|
+
// status/resume paths see the same routing the turn was dispatched with.
|
|
660
|
+
...(manifest.modelContext ? { modelContext: manifest.modelContext } : {}),
|
|
622
661
|
});
|
|
623
662
|
goal =
|
|
624
663
|
store.patch(
|
|
@@ -657,9 +696,17 @@ export async function runGoalLoop(input: RunGoalLoopInput): Promise<RunGoalLoopR
|
|
|
657
696
|
// multi-step workflows (fast-fix, implementation) work correctly. Without these, the
|
|
658
697
|
// team-runner's DAG scheduler / runtime resolution can throw unhandled rejections on
|
|
659
698
|
// the second batch, which the background-runner's rejection guard catches → silent exit.
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
699
|
+
let turnExecutedConfig: ReturnType<typeof effectiveRunConfig>;
|
|
700
|
+
let turnRuntime: Awaited<ReturnType<typeof resolveCrewRuntime>>;
|
|
701
|
+
if (input.deps.resolveTurnConfig) {
|
|
702
|
+
const resolved = input.deps.resolveTurnConfig(goal.cwd);
|
|
703
|
+
turnExecutedConfig = resolved.executedConfig;
|
|
704
|
+
turnRuntime = resolved.runtime;
|
|
705
|
+
} else {
|
|
706
|
+
const turnConfig = loadConfig(goal.cwd);
|
|
707
|
+
turnExecutedConfig = effectiveRunConfig(turnConfig.config, {});
|
|
708
|
+
turnRuntime = await resolveCrewRuntime(turnExecutedConfig);
|
|
709
|
+
}
|
|
663
710
|
turnResult = await executeTeamRun({
|
|
664
711
|
manifest: created.manifest,
|
|
665
712
|
tasks: created.tasks,
|
|
@@ -670,10 +717,37 @@ export async function runGoalLoop(input: RunGoalLoopInput): Promise<RunGoalLoopR
|
|
|
670
717
|
limits: turnExecutedConfig.limits,
|
|
671
718
|
runtime: turnRuntime,
|
|
672
719
|
runtimeConfig: turnExecutedConfig.runtime,
|
|
720
|
+
// Finding #1: restore the dispatch-time model routing (see
|
|
721
|
+
// restoredTurnModelRouting) — executeTeamRun in THIS process has no
|
|
722
|
+
// ExtensionContext to inherit the session model from.
|
|
723
|
+
...restoredTurnModelRouting(manifest),
|
|
673
724
|
reliability: turnExecutedConfig.reliability,
|
|
674
725
|
workspaceId: goal.ownerSessionId ?? goal.cwd,
|
|
675
726
|
signal,
|
|
676
727
|
});
|
|
728
|
+
} catch (error) {
|
|
729
|
+
// SR-01 (GL-1b part C, 2026-09-22): a throw between turn creation and
|
|
730
|
+
// executeTeamRun's own try (config resolution, runtime resolution, or
|
|
731
|
+
// executeTeamRun's PRE-try section) escaped with the turn manifest stuck
|
|
732
|
+
// at "queued" — a turn that is long dead still reported as in-flight, so
|
|
733
|
+
// `team status <turnId>` lied and a resume could double-run it. Mark it
|
|
734
|
+
// failed before re-throwing; the outer catch (GL-1b part B) then persists
|
|
735
|
+
// lastTurnError at the goal level.
|
|
736
|
+
//
|
|
737
|
+
// Guarded on status === "queued" so we never overwrite a status the
|
|
738
|
+
// executor already set (executeTeamRunCore failures already write failed
|
|
739
|
+
// themselves — no double-write). Best-effort: a failure to persist must
|
|
740
|
+
// not mask the original error.
|
|
741
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
742
|
+
try {
|
|
743
|
+
const loaded = loadRunManifestById(goal.cwd, created.manifest.runId);
|
|
744
|
+
if (loaded && loaded.manifest.status === "queued") {
|
|
745
|
+
updateRunStatus(loaded.manifest, "failed", `pre-execute failure: ${message}`);
|
|
746
|
+
}
|
|
747
|
+
} catch (persistError) {
|
|
748
|
+
logInternalError("goal-loop.markTurnFailed", persistError, `turnRunId=${created.manifest.runId}`);
|
|
749
|
+
}
|
|
750
|
+
throw error;
|
|
677
751
|
} finally {
|
|
678
752
|
unregisterActiveRun(created.manifest.runId);
|
|
679
753
|
}
|
|
@@ -689,6 +763,18 @@ export async function runGoalLoop(input: RunGoalLoopInput): Promise<RunGoalLoopR
|
|
|
689
763
|
// Previously the loop continued to evaluateGoal on blocked/failed turns, burning maxTurns.
|
|
690
764
|
const turnStatus = turnResult.manifest.status;
|
|
691
765
|
if (turnStatus === "blocked" || turnStatus === "failed") {
|
|
766
|
+
// GL-1b (2026-09-22 diagnosability): the failure reason previously lived
|
|
767
|
+
// ONLY inside the turn-run dir (manifest.summary + run.failed event) —
|
|
768
|
+
// auto-prune (keep=10, session start) deletes turn dirs, making blocked
|
|
769
|
+
// goals undiagnosable afterwards. Persist the reason into the goal state
|
|
770
|
+
// (never pruned), carry it on the terminal event, and clear the stale
|
|
771
|
+
// currentRunId — the turn is over.
|
|
772
|
+
const turnError = turnResult.manifest.summary ?? `turn ${turnIndex} ended ${turnStatus}`;
|
|
773
|
+
try {
|
|
774
|
+
goal = store.patch(goal.goalId, { lastTurnError: turnError, currentRunId: undefined }, eventsPath) ?? goal;
|
|
775
|
+
} catch (error) {
|
|
776
|
+
logInternalError("goal-loop.persistTurnError", error, `goalId=${goal.goalId}`);
|
|
777
|
+
}
|
|
692
778
|
goal = safeSetStatus(store, goal.goalId, "blocked", goal, eventsPath);
|
|
693
779
|
appendEventBuffered(eventsPath, {
|
|
694
780
|
type: "goal.turn_terminal_status",
|
|
@@ -698,6 +784,7 @@ export async function runGoalLoop(input: RunGoalLoopInput): Promise<RunGoalLoopR
|
|
|
698
784
|
turn: turnIndex,
|
|
699
785
|
turnRunId: created.manifest.runId,
|
|
700
786
|
turnStatus,
|
|
787
|
+
reason: turnError,
|
|
701
788
|
},
|
|
702
789
|
}).catch((e) => logInternalError("goal-loop.buffered", e, "type=goal.turn_terminal_status"));
|
|
703
790
|
break;
|
|
@@ -805,6 +892,20 @@ export async function runGoalLoop(input: RunGoalLoopInput): Promise<RunGoalLoopR
|
|
|
805
892
|
}
|
|
806
893
|
} catch (error) {
|
|
807
894
|
logInternalError("goal-loop.run", error, `goalId=${goal.goalId}`);
|
|
895
|
+
// GL-1b: this catch previously only console-errored (logInternalError) —
|
|
896
|
+
// loop-level failures left NO persisted trace. Mirror the turn-level fix:
|
|
897
|
+
// persist the message into the goal state + event log.
|
|
898
|
+
const loopError = error instanceof Error ? error.message : String(error);
|
|
899
|
+
try {
|
|
900
|
+
goal = store.patch(goal.goalId, { lastTurnError: loopError, currentRunId: undefined }, eventsPath) ?? goal;
|
|
901
|
+
} catch {
|
|
902
|
+
/* best-effort — status transition below still runs */
|
|
903
|
+
}
|
|
904
|
+
appendEventBuffered(eventsPath, {
|
|
905
|
+
type: "goal.loop_error",
|
|
906
|
+
runId: manifest.runId,
|
|
907
|
+
data: { goalId: goal.goalId, error: loopError },
|
|
908
|
+
}).catch((e) => logInternalError("goal-loop.buffered", e, "type=goal.loop_error"));
|
|
808
909
|
goal = safeSetStatus(store, goal.goalId, "blocked", goal, eventsPath);
|
|
809
910
|
} finally {
|
|
810
911
|
// P1g: release the workspace lock (held since loop start).
|
|
@@ -59,9 +59,17 @@ export function deliverGroupJoin(input: {
|
|
|
59
59
|
}): CrewGroupJoinDelivery | undefined {
|
|
60
60
|
if (!shouldGroupJoin(input.mode, input.batch)) return undefined;
|
|
61
61
|
const taskIds = input.batch.map((task) => task.id);
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
62
|
+
// RR-021 WI-4.3d: first-wins Map — the old taskIds.map(id => allTasks.find())
|
|
63
|
+
// was O(batch×allTasks). NOT a raw input.batch pass-through: callers may
|
|
64
|
+
// hold stale batch snapshots, and the only audited production caller
|
|
65
|
+
// (team-runner closeout) passes fresh references — but the Map keeps the
|
|
66
|
+
// exact `.find` first-match semantics for every caller (incl. tests) while
|
|
67
|
+
// staying O(n+m). Batch order is preserved (map follows taskIds).
|
|
68
|
+
const taskById = new Map<string, TeamTaskState>();
|
|
69
|
+
for (const task of input.allTasks) {
|
|
70
|
+
if (!taskById.has(task.id)) taskById.set(task.id, task);
|
|
71
|
+
}
|
|
72
|
+
const latest = taskIds.map((id) => taskById.get(id)).filter((task): task is TeamTaskState => Boolean(task));
|
|
65
73
|
const completed = statusList(latest, "completed");
|
|
66
74
|
const failed = statusList(latest, "failed");
|
|
67
75
|
const skipped = statusList(latest, "skipped");
|
|
@@ -98,6 +98,13 @@ export class HeartbeatWatcher {
|
|
|
98
98
|
if (loaded.manifest.status !== "running") continue;
|
|
99
99
|
for (const task of loaded.tasks) {
|
|
100
100
|
if (task.status !== "running") continue;
|
|
101
|
+
// Guest-child tasks (delegate subagents, agent === "delegate") have NO
|
|
102
|
+
// heartbeat channel: they never write task.heartbeat/agentProgress —
|
|
103
|
+
// their lifecycle is tracked by delegate.requested/admitted/completed
|
|
104
|
+
// broker events. Watching them here classified every guest as "dead"
|
|
105
|
+
// within one poll (52ms after admit, run team_20260926033657_2b6c6d2610b26d9d,
|
|
106
|
+
// 2026-09-26 battery) and enqueued false "stuck worker" ambients.
|
|
107
|
+
if (task.agent === "delegate") continue;
|
|
101
108
|
const key = `${run.runId}:${task.id}`;
|
|
102
109
|
activeKeys.add(key);
|
|
103
110
|
this.lastSeen.set(key, now);
|
|
@@ -96,7 +96,7 @@ let liveSessionModulePromise: Promise<LiveSessionModule> | undefined;
|
|
|
96
96
|
let experimentalWarned = false;
|
|
97
97
|
function loadLiveSessionModule(): Promise<LiveSessionModule> {
|
|
98
98
|
if (!liveSessionModulePromise) {
|
|
99
|
-
liveSessionModulePromise = import("@earendil-works/pi-coding-agent") as unknown as Promise<LiveSessionModule>;
|
|
99
|
+
liveSessionModulePromise = import("@earendil-works/pi-coding-agent") as unknown as Promise<LiveSessionModule>; // LAZY: defer pi-coding-agent live-session module until first use
|
|
100
100
|
}
|
|
101
101
|
return liveSessionModulePromise;
|
|
102
102
|
}
|
|
@@ -70,6 +70,20 @@ function modelInfoFromUnknown(value: unknown): AvailableModelInfo | undefined {
|
|
|
70
70
|
};
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
+
// US-012 (2026-09-22): cache the registry-derived model list. The registry is
|
|
74
|
+
// config/env-derived and invariant per session, but `availableModelInfosFromRegistry`
|
|
75
|
+
// is called on EVERY model resolution / worker spawn (a 4-worker team = 4
|
|
76
|
+
// rebuilds). Keyed on the registry object identity (WeakMap) so a new registry
|
|
77
|
+
// instance (config reload) gets a fresh entry automatically, and invalidated by
|
|
78
|
+
// the getAvailable() array identity so a mutated-in-place list is not served
|
|
79
|
+
// stale. WeakMap → no leak when a session's registry is dropped.
|
|
80
|
+
const registryModelCache = new WeakMap<object, { raw: unknown[]; infos: AvailableModelInfo[] }>();
|
|
81
|
+
|
|
82
|
+
/** @internal — test introspection. */
|
|
83
|
+
export function __test__registryModelCacheHas(registry: object): boolean {
|
|
84
|
+
return registryModelCache.has(registry);
|
|
85
|
+
}
|
|
86
|
+
|
|
73
87
|
export function availableModelInfosFromRegistry(registry: unknown): AvailableModelInfo[] | undefined {
|
|
74
88
|
if (!registry || typeof registry !== "object" || Array.isArray(registry)) return undefined;
|
|
75
89
|
const candidate = registry as ModelRegistryLike;
|
|
@@ -80,7 +94,13 @@ export function availableModelInfosFromRegistry(registry: unknown): AvailableMod
|
|
|
80
94
|
? candidate.getAll()
|
|
81
95
|
: undefined;
|
|
82
96
|
if (!Array.isArray(raw)) return undefined;
|
|
83
|
-
|
|
97
|
+
// Serve from cache only when the SAME array instance is returned — a
|
|
98
|
+
// rebuilt/refreshed list (different identity) must be re-normalized.
|
|
99
|
+
const cached = registryModelCache.get(registry as object);
|
|
100
|
+
if (cached && cached.raw === raw) return cached.infos;
|
|
101
|
+
const infos = raw.map(modelInfoFromUnknown).filter((entry): entry is AvailableModelInfo => entry !== undefined);
|
|
102
|
+
registryModelCache.set(registry as object, { raw, infos });
|
|
103
|
+
return infos;
|
|
84
104
|
}
|
|
85
105
|
|
|
86
106
|
export function modelStringFromUnknown(model: unknown): string | undefined {
|
|
@@ -3,7 +3,7 @@ import * as os from "node:os";
|
|
|
3
3
|
import * as path from "node:path";
|
|
4
4
|
import type { AgentConfig } from "../../agents/agent-config.ts";
|
|
5
5
|
import { atomicWriteFile } from "../../state/atomic-write.ts";
|
|
6
|
-
import { packageRoot, userPiRoot } from "../../utils/paths.ts";
|
|
6
|
+
import { hasRunStateLayout, packageRoot, userPiRoot } from "../../utils/paths.ts";
|
|
7
7
|
|
|
8
8
|
const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh"];
|
|
9
9
|
// FIX (2026-07-02): use packageRoot() instead of import.meta.url-relative path.
|
|
@@ -629,16 +629,14 @@ export function cleanupLegacyOrphanTempDirs(
|
|
|
629
629
|
continue;
|
|
630
630
|
}
|
|
631
631
|
if (lstat.isSymbolicLink()) continue;
|
|
632
|
-
// Skip dirs containing
|
|
632
|
+
// Skip dirs containing pi-crew run state — those are handled by
|
|
633
633
|
// reconcileOrphanedTempWorkspaces which has run-state semantics.
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
}
|
|
641
|
-
if (crewDirLstat && !crewDirLstat.isSymbolicLink()) continue;
|
|
634
|
+
// RR-020 Fix 3: recognise BOTH supported layouts — `<dir>/.crew/` and
|
|
635
|
+
// the `.pi`-based `<dir>/.pi/teams/`. Only `.crew` used to be checked,
|
|
636
|
+
// so a temp workspace with live `.pi/teams` run state was deleted as
|
|
637
|
+
// debris. Symlinked layout dirs still do NOT protect the dir (the
|
|
638
|
+
// scanner keeps rejecting symlinks instead of trusting them).
|
|
639
|
+
if (hasRunStateLayout(dir)) continue;
|
|
642
640
|
// Skip dirs currently tracked by this process (defense in depth:
|
|
643
641
|
// with 8ba270d the Set should never contain /tmp/ paths, but
|
|
644
642
|
// future code or external callers might).
|
|
@@ -124,6 +124,7 @@ export function startRuntimeWarmup(): void {
|
|
|
124
124
|
for (const spec of HOT_MODULE_SPECIFIERS) {
|
|
125
125
|
imports.push(
|
|
126
126
|
import(new URL(spec, import.meta.url).href).catch(() => {
|
|
127
|
+
// LAZY: best-effort warmup import, failures tolerated
|
|
127
128
|
// swallow — never block registration on a warmup failure
|
|
128
129
|
}),
|
|
129
130
|
);
|
|
@@ -131,6 +132,7 @@ export function startRuntimeWarmup(): void {
|
|
|
131
132
|
for (const dep of HOT_PEER_DEPS) {
|
|
132
133
|
imports.push(
|
|
133
134
|
import(dep).catch(() => {
|
|
135
|
+
// LAZY: best-effort warmup import, failures tolerated
|
|
134
136
|
// peer dep may be absent (optional dep) — swallow
|
|
135
137
|
}),
|
|
136
138
|
);
|