@zq-silk/yui 0.2.0 → 0.4.2
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/ARCHITECTURE.md +603 -133
- package/README.md +806 -31
- package/dist/agent/agent.js +2 -1
- package/dist/agent/argumentPolicy.js +3 -1
- package/dist/agent/launchEnvironment.js +106 -0
- package/dist/agent/managedRuntimeEnvironment.js +34 -0
- package/dist/brief/taskBrief.js +11 -1
- package/dist/cli/agentConfigurationPicker.js +287 -0
- package/dist/cli/commandCatalog.js +488 -60
- package/dist/cli/completion.js +146 -22
- package/dist/cli/helpRenderer.js +3 -1
- package/dist/cli/interactionCandidates.js +53 -15
- package/dist/cli/interactionPolicy.js +267 -30
- package/dist/cli/interactiveSelection.js +6 -2
- package/dist/cli/invocationRouter.js +5 -1
- package/dist/cli/operatorWizard.js +87 -0
- package/dist/cli/roleOptionCatalog.js +1 -0
- package/dist/cli/roleWizard.js +185 -21
- package/dist/cli/updateCommand.js +62 -19
- package/dist/cli/updateOrchestrator.js +539 -0
- package/dist/cli/updatePorts.js +1119 -0
- package/dist/cli/upgradeCommand.js +112 -0
- package/dist/cli.js +1420 -86
- package/dist/commands/agentCommands.js +146 -3
- package/dist/commands/configCommands.js +126 -0
- package/dist/commands/controllerCommands.js +365 -0
- package/dist/commands/globalRoleCommands.js +168 -126
- package/dist/commands/jobCommands.js +18 -8
- package/dist/commands/operatorCommands.js +159 -9
- package/dist/commands/profileCommands.js +203 -0
- package/dist/commands/projectCommands.js +650 -0
- package/dist/commands/roleConfiguration.js +85 -24
- package/dist/commands/roleRuntimeGuard.js +12 -0
- package/dist/commands/roleSkillValidation.js +47 -0
- package/dist/commands/taskActor.js +127 -0
- package/dist/commands/taskCommands.js +4201 -313
- package/dist/commands/taskCompletionGate.js +131 -0
- package/dist/commands/taskContextCommand.js +244 -30
- package/dist/commands/taskInputCommands.js +177 -59
- package/dist/commands/taskIntegrationCommands.js +303 -0
- package/dist/commands/taskOverviewCommand.js +363 -0
- package/dist/commands/taskRoleRuntimeStatus.js +125 -19
- package/dist/commands/textInput.js +15 -0
- package/dist/completion/completionInstaller.js +26 -22
- package/dist/config/yuiConfig.js +4 -3
- package/dist/context/dispatchContext.js +90 -38
- package/dist/context/roleSessionContext.js +119 -0
- package/dist/controller/claudeLifecycleHook.js +203 -0
- package/dist/controller/clientRuntime.js +408 -56
- package/dist/controller/codexLifecycleHook.js +108 -0
- package/dist/controller/controller.js +1089 -32
- package/dist/controller/domainIdentity.js +505 -0
- package/dist/controller/ephemeralResourceReaper.js +131 -0
- package/dist/controller/fileSchedulerStoreAdapter.js +2153 -103
- package/dist/controller/providerHookRunFence.js +127 -0
- package/dist/controller/resourceCleanupLinux.js +286 -0
- package/dist/controller/resourceInventory.js +531 -0
- package/dist/controller/resourceInventoryLinux.js +610 -0
- package/dist/controller/runtime.js +629 -10
- package/dist/controller/runtimeEventInbox.js +564 -0
- package/dist/controller/runtimeEventProcessor.js +248 -0
- package/dist/controller/runtimeLaunchCoordinator.js +477 -0
- package/dist/controller/sessionNotify.js +121 -78
- package/dist/coordination/deadlineScheduler.js +15 -0
- package/dist/coordination/mailboxScheduler.js +108 -0
- package/dist/coordination/workMailbox.js +329 -0
- package/dist/coordination/workMailboxQueue.js +86 -0
- package/dist/core/controllerClient.js +19 -5
- package/dist/core/controllerEndpoint.js +37 -0
- package/dist/core/controllerServer.js +218 -10
- package/dist/core/protocol.js +6 -2
- package/dist/decision/decision.js +2 -1
- package/dist/doctor/doctor.js +681 -32
- package/dist/domain/validation.js +53 -0
- package/dist/errors/cliError.js +5 -3
- package/dist/event/taskEvent.js +7 -3
- package/dist/execution/codexThreadNaming.js +160 -0
- package/dist/execution/executionGroup.js +579 -0
- package/dist/executor/agentAdapter.js +255 -40
- package/dist/executor/agentConfigurationCatalog.js +326 -0
- package/dist/executor/agentConfigurationProbe.js +506 -0
- package/dist/executor/agentExecutor.js +625 -10
- package/dist/executor/codexConfigConflict.js +290 -0
- package/dist/executor/effectiveLaunch.js +340 -0
- package/dist/executor/executorRegistry.js +238 -36
- package/dist/executor/fileRoleLaunchPlanner.js +550 -40
- package/dist/executor/turnCompletion.js +126 -0
- package/dist/input/inputRequest.js +30 -9
- package/dist/integration/changeSet.js +36 -0
- package/dist/integration/checkResult.js +24 -0
- package/dist/integration/gitIntegrationService.js +695 -0
- package/dist/integration/integrationAttempt.js +142 -0
- package/dist/interaction/operatorPresentation.js +96 -0
- package/dist/lifecycle/canonicalLifecycleEvent.js +342 -0
- package/dist/lifecycle/exactRunTerminalization.js +572 -0
- package/dist/lifecycle/providerLifecycleMapping.js +190 -0
- package/dist/lifecycle/taskRoleSessionReset.js +124 -0
- package/dist/message/message.js +23 -7
- package/dist/milestone/milestone.js +2 -1
- package/dist/operator/operatorSessionHistory.js +124 -0
- package/dist/output/agentConfigurationPresentation.js +43 -0
- package/dist/output/rolePresentation.js +34 -10
- package/dist/output/terminal.js +8 -0
- package/dist/output/timePresentation.js +55 -0
- package/dist/profile/agentProfile.js +128 -0
- package/dist/repository/gitWorkspace.js +578 -24
- package/dist/repository/project.js +213 -0
- package/dist/repository/taskWorkspaceCoordinator.js +392 -0
- package/dist/repository/taskWorkspacePreparer.js +1688 -191
- package/dist/review/reviewConfig.js +11 -0
- package/dist/review/reviewRound.js +399 -0
- package/dist/review/taskFinalReviewContract.js +90 -0
- package/dist/role/role.js +124 -23
- package/dist/run/agentRun.js +155 -12
- package/dist/run/runIdentity.js +82 -0
- package/dist/runtime/exactControlPlane.js +472 -0
- package/dist/runtime/index.js +8 -0
- package/dist/runtime/lifecycleReservation.js +38 -0
- package/dist/runtime/ports.js +11 -0
- package/dist/runtime/preallocatedNativeSession.js +13 -0
- package/dist/runtime/promptEnvelope.js +30 -0
- package/dist/runtime/runtimeBinding.js +31 -0
- package/dist/runtime/runtimeOwner.js +14 -0
- package/dist/runtime/sessionLaunchRequest.js +62 -0
- package/dist/runtime/sessionTitle.js +54 -0
- package/dist/runtime/taskRuntimeIsolation.js +643 -0
- package/dist/runtime/tmuxAdapters.js +315 -0
- package/dist/runtime/turnCompletion.js +3 -0
- package/dist/runtime/validation.js +23 -0
- package/dist/scheduler/activeRoleRunDelivery.js +342 -32
- package/dist/scheduler/activeTaskProgress.js +63 -0
- package/dist/scheduler/leaderFailure.js +2 -1
- package/dist/scheduler/leaderWakeupProcessor.js +307 -66
- package/dist/scheduler/operatorInputNotificationProcessor.js +109 -46
- package/dist/scheduler/operatorNotification.js +44 -2
- package/dist/scheduler/ports.js +28 -1
- package/dist/scheduler/roleRunLiveness.js +131 -25
- package/dist/scheduler/roleRunStall.js +951 -0
- package/dist/scheduler/taskExecutionProjection.js +544 -0
- package/dist/scheduler/wakeupQueue.js +3 -0
- package/dist/setup/setupCommand.js +302 -52
- package/dist/storage/compatibleTaskStore.js +102 -0
- package/dist/storage/migration/baseline.js +78 -0
- package/dist/storage/migration/classifier.js +51 -0
- package/dist/storage/migration/compatibleCodec.js +53 -0
- package/dist/storage/migration/engine.js +147 -0
- package/dist/storage/migration/index.js +33 -0
- package/dist/storage/migration/planner.js +154 -0
- package/dist/storage/migration/productionRegistry.js +486 -0
- package/dist/storage/migration/registry.js +169 -0
- package/dist/storage/migration/report.js +54 -0
- package/dist/storage/migration/types.js +31 -0
- package/dist/storage/storageSchema.js +147 -123
- package/dist/storage/storageVersions.js +11 -0
- package/dist/storage/taskStore.js +1793 -197
- package/dist/storage/upgrade/homeClassification.js +156 -0
- package/dist/storage/upgrade/homeMigrationTarget.js +595 -0
- package/dist/storage/upgrade/offlineUpgradeInventory.js +315 -0
- package/dist/storage/upgrade/productionMigrationRegistry.js +6 -0
- package/dist/storage/upgrade/recordVersionScan.js +176 -0
- package/dist/storage/upgrade/recordVersions.js +159 -0
- package/dist/storage/upgrade/switchProgress.js +80 -0
- package/dist/storage/upgrade/upgradeOrchestrator.js +948 -0
- package/dist/storage/upgrade/upgradeReceipt.js +161 -0
- package/dist/storage/upgradeCoordination.js +186 -0
- package/dist/storage/upgradeFence.js +366 -0
- package/dist/task/task.js +132 -26
- package/dist/task/taskRecordReference.js +66 -0
- package/dist/tmux/commandExecutor.js +75 -2
- package/dist/tmux/tmuxManager.js +747 -49
- package/dist/version.js +23 -0
- package/dist/web/assets/assetManifest.js +62 -0
- package/dist/web/assets/client/app.js +631 -0
- package/dist/web/assets/client/components.js +605 -0
- package/dist/web/assets/client/dom.js +14 -0
- package/dist/web/assets/client/format.js +28 -0
- package/dist/web/assets/client/i18n.js +494 -0
- package/dist/web/assets/client/markdown.js +114 -0
- package/dist/web/assets/client/theme.js +32 -0
- package/dist/web/assets/client/view.js +458 -0
- package/dist/web/assets/fontData.js +12 -0
- package/dist/web/assets/fonts.js +12 -0
- package/dist/web/assets/shell.js +114 -0
- package/dist/web/assets/styles/cards.js +135 -0
- package/dist/web/assets/styles/layout.js +47 -0
- package/dist/web/assets/styles/markdown.js +29 -0
- package/dist/web/assets/styles/responsive.js +39 -0
- package/dist/web/assets/styles/tokens.js +101 -0
- package/dist/web/assets/styles/widgets.js +147 -0
- package/dist/web/tmuxWebTerminal.js +158 -0
- package/dist/web/webServer.js +463 -0
- package/dist/web/webSnapshot.js +148 -0
- package/dist/workItem/workItem.js +642 -23
- package/dist/workspace/gitChangeSetCapture.js +86 -0
- package/dist/workspace/workItemChangeSetManager.js +445 -0
- package/dist/worktree/managedWorkspace.js +202 -0
- package/docs/task-local-identity.md +62 -0
- package/i18n/README.zh-CN.md +406 -31
- package/package.json +10 -2
- package/skills/yui-leader/SKILL.md +601 -39
- package/skills/yui-operator/SKILL.md +255 -34
- package/skills/yui-reviewer/SKILL.md +57 -0
- package/skills/yui-worker/SKILL.md +214 -17
- package/dist/commands/repositoryCommands.js +0 -86
- package/dist/operator/operatorContext.js +0 -66
- package/dist/repository/repository.js +0 -55
- package/dist/scheduler/archivedTaskRuntime.js +0 -12
- package/dist/worktree/roleWorkspace.js +0 -62
|
@@ -0,0 +1,951 @@
|
|
|
1
|
+
import { selectedSchedulerRoles, selectedSchedulerTasks } from "./ports.js";
|
|
2
|
+
/**
|
|
3
|
+
* Default window of no durable progress before a live-but-idle Run becomes a
|
|
4
|
+
* traceable needs-attention signal. It is deliberately long: a healthy Run that
|
|
5
|
+
* is simply slow keeps its structured checkpoint fresh and never crosses it.
|
|
6
|
+
*/
|
|
7
|
+
export const DEFAULT_STALL_WINDOW_MS = 30 * 60_000;
|
|
8
|
+
/** Cheap execution-stall candidate filter; the real threshold remains 30m. */
|
|
9
|
+
export const DEFAULT_EXECUTION_STALL_CANDIDATE_AGE_MS = 10 * 60_000;
|
|
10
|
+
/** Resource activity may postpone attention only within this semantic gap. */
|
|
11
|
+
export const DEFAULT_RESOURCE_ONLY_SEMANTIC_GAP_MS = 90 * 60_000;
|
|
12
|
+
export const RUN_PROGRESS_EVENT = "run.progress";
|
|
13
|
+
export const RUN_STALLED_EVENT = "run.stalled";
|
|
14
|
+
export const RUN_RECOVERED_EVENT = "run.recovered";
|
|
15
|
+
/** Durable one-shot advisory resource evidence; never a progress fact. */
|
|
16
|
+
export const RUN_RESOURCE_SUPPRESSED_EVENT = "run.resource-suppressed";
|
|
17
|
+
/** Structured, non-Message recovery evidence written by an explicit Leader. */
|
|
18
|
+
export const RUN_RECOVERY_REQUESTED_EVENT = "run.recovery-requested";
|
|
19
|
+
export const RUN_RECOVERY_APPLIED_EVENT = "run.recovery-applied";
|
|
20
|
+
/**
|
|
21
|
+
* Clear only the Operator projection raised for this exact Leader Run. A
|
|
22
|
+
* later Run (or a recovery notification of another kind) must remain intact;
|
|
23
|
+
* callers use this helper from every supported recovery/terminalization path
|
|
24
|
+
* so attention cleanup cannot drift between CLI and runtime code.
|
|
25
|
+
*/
|
|
26
|
+
export function clearMatchingLeaderStallAttention(store, taskId, runId) {
|
|
27
|
+
const notification = store.getOperatorNotification(taskId);
|
|
28
|
+
if (notification?.type !== "leader-stalled" || notification.runId !== runId) {
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
store.clearOperatorNotification(taskId);
|
|
32
|
+
return true;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* One pure projection used by every Role. Resource activity can suppress a
|
|
36
|
+
* false positive only together with a live host and matching native Session
|
|
37
|
+
* or launch generation;
|
|
38
|
+
* it never changes the durable progress clock or authorizes recovery.
|
|
39
|
+
*/
|
|
40
|
+
export function projectRoleRunHealth(input) {
|
|
41
|
+
const windowMs = input.windowMs ?? DEFAULT_STALL_WINDOW_MS;
|
|
42
|
+
if (!Number.isFinite(windowMs) || windowMs <= 0) {
|
|
43
|
+
throw new Error("Role run stall window must be a positive number of milliseconds.");
|
|
44
|
+
}
|
|
45
|
+
const evaluation = evaluateRoleRunStall({
|
|
46
|
+
progressAt: input.progressAt,
|
|
47
|
+
now: input.now,
|
|
48
|
+
windowMs
|
|
49
|
+
});
|
|
50
|
+
const idleMs = evaluation.idleMs;
|
|
51
|
+
const candidateStart = input.deliveredAt ?? input.createdAt;
|
|
52
|
+
const candidateAge = input.now.getTime() - Date.parse(candidateStart);
|
|
53
|
+
const candidate = Number.isFinite(candidateAge)
|
|
54
|
+
&& (input.deliveredAt === undefined
|
|
55
|
+
? candidateAge >= windowMs
|
|
56
|
+
: candidateAge >= DEFAULT_EXECUTION_STALL_CANDIDATE_AGE_MS);
|
|
57
|
+
const providerAcceptance = input.providerAcceptance
|
|
58
|
+
?? (input.deliveredAt === undefined ? "ambiguous" : "accepted");
|
|
59
|
+
const hostLiveness = input.hostLiveness;
|
|
60
|
+
const session = input.nativeSession;
|
|
61
|
+
const nativeSession = session === null || session === undefined
|
|
62
|
+
? "missing"
|
|
63
|
+
: session.status === "stopped"
|
|
64
|
+
? "stopped"
|
|
65
|
+
: session.status === "broken"
|
|
66
|
+
? "broken"
|
|
67
|
+
: session.status !== "ready" && session.status !== "running"
|
|
68
|
+
? "unknown"
|
|
69
|
+
: !hasResourceIdentityText(session.nativeSessionId)
|
|
70
|
+
&& !hasResourceIdentityText(session.launchId)
|
|
71
|
+
? "unknown"
|
|
72
|
+
: "matching";
|
|
73
|
+
const resourceActivity = hostLiveness === "present"
|
|
74
|
+
&& nativeSession === "matching"
|
|
75
|
+
&& resourceEvidenceIsFresh(input.resource, input.now, windowMs)
|
|
76
|
+
// Residency/RSS and an unchanged cumulative counter are not progress.
|
|
77
|
+
&& input.resource?.active === true
|
|
78
|
+
&& input.resource?.changed === true;
|
|
79
|
+
const waitingUser = input.waitingUser === true;
|
|
80
|
+
const waitingOnWorkers = input.waitingOnWorkers === true && !input.staleLeaderMailbox;
|
|
81
|
+
// A TmuxSessionHost binding may intentionally be opaque and therefore have
|
|
82
|
+
// no persisted nativeSessionId. With a present host, durable no-progress
|
|
83
|
+
// evidence is still actionable in that case. Only an explicit stopped or
|
|
84
|
+
// broken Session blocks the projection; identity mismatches are fenced by
|
|
85
|
+
// reconcileStalledRoleRuns below before this projection is routed.
|
|
86
|
+
const executionStall = candidate
|
|
87
|
+
&& evaluation.stalled
|
|
88
|
+
&& hostLiveness === "present"
|
|
89
|
+
&& nativeSession !== "stopped"
|
|
90
|
+
&& nativeSession !== "broken"
|
|
91
|
+
&& providerAcceptance !== "ambiguous"
|
|
92
|
+
&& !resourceActivity;
|
|
93
|
+
const stalled = executionStall
|
|
94
|
+
&& !waitingUser
|
|
95
|
+
&& !waitingOnWorkers;
|
|
96
|
+
const classification = waitingUser
|
|
97
|
+
? "waiting-user"
|
|
98
|
+
: waitingOnWorkers
|
|
99
|
+
? "waiting-on-workers"
|
|
100
|
+
: stalled
|
|
101
|
+
? "truly-stalled"
|
|
102
|
+
: candidate && input.roleName === "leader"
|
|
103
|
+
? "working"
|
|
104
|
+
: "working";
|
|
105
|
+
return {
|
|
106
|
+
candidate,
|
|
107
|
+
stalled,
|
|
108
|
+
classification,
|
|
109
|
+
providerAcceptance,
|
|
110
|
+
hostLiveness,
|
|
111
|
+
nativeSession,
|
|
112
|
+
resourceActivity,
|
|
113
|
+
progressAt: input.progressAt,
|
|
114
|
+
idleMs
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Pure stall decision. Given the last durable progress timestamp and whatever
|
|
119
|
+
* progress point the previous attention (if any) recorded, decide whether the
|
|
120
|
+
* Run is stalled and whether this is new evidence worth surfacing again.
|
|
121
|
+
*/
|
|
122
|
+
export function evaluateRoleRunStall(input) {
|
|
123
|
+
const windowMs = input.windowMs ?? DEFAULT_STALL_WINDOW_MS;
|
|
124
|
+
if (!Number.isFinite(windowMs) || windowMs <= 0) {
|
|
125
|
+
throw new Error("Role run stall window must be a positive number of milliseconds.");
|
|
126
|
+
}
|
|
127
|
+
const progressMs = Date.parse(input.progressAt);
|
|
128
|
+
if (!Number.isFinite(progressMs)) {
|
|
129
|
+
throw new Error("Role run stall progress timestamp is invalid.");
|
|
130
|
+
}
|
|
131
|
+
const idleMs = input.now.getTime() - progressMs;
|
|
132
|
+
const stalled = idleMs >= windowMs;
|
|
133
|
+
const isNewEpisode = stalled && input.lastAttentionProgressAt !== input.progressAt;
|
|
134
|
+
return { stalled, isNewEpisode, idleMs, progressAt: input.progressAt };
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Latest durable progress timestamp for an active Run. Provider acceptance
|
|
138
|
+
* (deliveredAt), explicit structured checkpoints, and semantic Run-scoped
|
|
139
|
+
* activity all count as progress; bookkeeping-only Run/Session timestamps are
|
|
140
|
+
* intentionally excluded. CPU/memory are never inputs here.
|
|
141
|
+
*/
|
|
142
|
+
export function latestDurableProgressAt(input) {
|
|
143
|
+
const candidates = [
|
|
144
|
+
input.deliveredAt,
|
|
145
|
+
input.latestCheckpointAt,
|
|
146
|
+
input.latestActivityAt
|
|
147
|
+
].filter((value) => (typeof value === "string" && Number.isFinite(Date.parse(value))));
|
|
148
|
+
if (candidates.length === 0) {
|
|
149
|
+
throw new Error("No durable progress timestamp is available for the Run.");
|
|
150
|
+
}
|
|
151
|
+
return candidates.reduce((latest, value) => (Date.parse(value) > Date.parse(latest) ? value : latest));
|
|
152
|
+
}
|
|
153
|
+
/** Most recent semantic timestamp carried by a Run progress event. */
|
|
154
|
+
export function latestRunProgressAt(events, runId) {
|
|
155
|
+
let latest;
|
|
156
|
+
for (const event of events) {
|
|
157
|
+
if (event.type !== RUN_PROGRESS_EVENT
|
|
158
|
+
&& event.type !== "runtime.provider-turn-progress")
|
|
159
|
+
continue;
|
|
160
|
+
if (event.payload.runId !== runId)
|
|
161
|
+
continue;
|
|
162
|
+
if (event.type === "runtime.provider-turn-progress"
|
|
163
|
+
&& (typeof event.payload.progressAt !== "string"
|
|
164
|
+
|| !Number.isFinite(Date.parse(event.payload.progressAt))))
|
|
165
|
+
continue;
|
|
166
|
+
const progressAt = typeof event.payload.progressAt === "string"
|
|
167
|
+
&& Number.isFinite(Date.parse(event.payload.progressAt))
|
|
168
|
+
? event.payload.progressAt
|
|
169
|
+
: event.createdAt;
|
|
170
|
+
// The semantic timestamp is the CAS value. A late/stale event must not
|
|
171
|
+
// move the durable progress projection backwards merely because it was
|
|
172
|
+
// appended later than a newer checkpoint.
|
|
173
|
+
if (latest === undefined || Date.parse(progressAt) > Date.parse(latest)) {
|
|
174
|
+
latest = progressAt;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return latest;
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* Resolve the exact semantic progress fence shared by resource production and
|
|
181
|
+
* stall consumption for one active Run. The richer adapter fold remains
|
|
182
|
+
* authoritative when available; the retained Run progress/activity events are
|
|
183
|
+
* the provider-neutral fallback. Resource evidence carries this value as an
|
|
184
|
+
* exact fence, but never advances it.
|
|
185
|
+
*/
|
|
186
|
+
export function currentRoleRunProgressAt(store, taskId, roleName, run, events = store.listEvents?.(taskId) ?? []) {
|
|
187
|
+
let richerProgress;
|
|
188
|
+
try {
|
|
189
|
+
richerProgress = store.getRunDurableProgress?.(taskId, roleName, run.id);
|
|
190
|
+
}
|
|
191
|
+
catch {
|
|
192
|
+
// A related-record fold is advisory. The retained Run/Event fence remains
|
|
193
|
+
// usable when that optional read is unavailable.
|
|
194
|
+
richerProgress = undefined;
|
|
195
|
+
}
|
|
196
|
+
if (run.deliveredAt === undefined)
|
|
197
|
+
return { progressAt: run.createdAt };
|
|
198
|
+
const fallbackProgressAt = latestDurableProgressAt({
|
|
199
|
+
deliveredAt: run.deliveredAt,
|
|
200
|
+
latestCheckpointAt: latestRunProgressAt(events, run.id),
|
|
201
|
+
latestActivityAt: latestRunActivityAt(events, run.id)
|
|
202
|
+
});
|
|
203
|
+
return {
|
|
204
|
+
progressAt: richerProgress?.progressAt ?? fallbackProgressAt,
|
|
205
|
+
...(richerProgress?.evidence === undefined
|
|
206
|
+
? {}
|
|
207
|
+
: { evidence: richerProgress.evidence })
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* Computes the current semantic progress fence for an exact Run. The optional
|
|
212
|
+
* related-record readers mirror the adapter's Work/Review/Integration fold;
|
|
213
|
+
* narrow scheduler ports can omit them and still retain Run/Event evidence.
|
|
214
|
+
*/
|
|
215
|
+
export function latestRunDurableProgressAt(store, taskId, roleName, runId) {
|
|
216
|
+
const run = store.getAgentRun(taskId, runId);
|
|
217
|
+
if (run === null || run.taskId !== taskId || run.roleName !== roleName)
|
|
218
|
+
return null;
|
|
219
|
+
const events = store.listEvents(taskId);
|
|
220
|
+
const latestCheckpointAt = latestRunProgressAt(events, run.id);
|
|
221
|
+
const latestActivityAt = latestRunActivityAt(events, run.id);
|
|
222
|
+
const baseline = run.deliveredAt === undefined
|
|
223
|
+
? run.createdAt
|
|
224
|
+
: latestDurableProgressAt({
|
|
225
|
+
deliveredAt: run.deliveredAt,
|
|
226
|
+
latestCheckpointAt,
|
|
227
|
+
latestActivityAt
|
|
228
|
+
});
|
|
229
|
+
if (run.workItemId === undefined) {
|
|
230
|
+
return { progressAt: baseline };
|
|
231
|
+
}
|
|
232
|
+
const workItem = store.getWorkItem?.(taskId, run.workItemId) ?? null;
|
|
233
|
+
const reviewRounds = store.listReviewRounds?.(taskId)
|
|
234
|
+
.filter(({ workItemId }) => workItemId === run.workItemId)
|
|
235
|
+
?? [];
|
|
236
|
+
const changeSets = store.listChangeSets?.(taskId)
|
|
237
|
+
.filter(({ workItemId }) => workItemId === run.workItemId)
|
|
238
|
+
?? [];
|
|
239
|
+
const changeSetIds = new Set(changeSets.map(({ id }) => id));
|
|
240
|
+
const integrations = store.listIntegrationAttempts?.(taskId)
|
|
241
|
+
.filter(({ changeSetIds: ids }) => ids.some((id) => changeSetIds.has(id)))
|
|
242
|
+
?? [];
|
|
243
|
+
const inputProgress = store.listInputRequests?.(taskId)
|
|
244
|
+
.filter((request) => (request.requester.runId === run.id
|
|
245
|
+
|| request.blockedRefs.some((ref) => ref.type === "run" && ref.id === run.id)))
|
|
246
|
+
?? [];
|
|
247
|
+
const related = [
|
|
248
|
+
workItem?.updatedAt,
|
|
249
|
+
...reviewRounds.map(({ endedAt, createdAt }) => endedAt ?? createdAt),
|
|
250
|
+
...changeSets.map(({ createdAt }) => createdAt),
|
|
251
|
+
...integrations.map(({ updatedAt }) => updatedAt),
|
|
252
|
+
...inputProgress.map(({ updatedAt }) => updatedAt),
|
|
253
|
+
...(workItem?.candidates ?? []).map(({ createdAt }) => createdAt)
|
|
254
|
+
].filter((value) => (typeof value === "string" && Number.isFinite(Date.parse(value))));
|
|
255
|
+
const latestRelatedAt = related.reduce((latest, value) => latest === undefined || Date.parse(value) > Date.parse(latest)
|
|
256
|
+
? value
|
|
257
|
+
: latest, undefined);
|
|
258
|
+
// Before provider acceptance the Run clock remains anchored to creation,
|
|
259
|
+
// while related records are retained as diagnostic evidence only.
|
|
260
|
+
return run.deliveredAt !== undefined
|
|
261
|
+
&& latestRelatedAt !== undefined
|
|
262
|
+
&& Date.parse(latestRelatedAt) > Date.parse(baseline)
|
|
263
|
+
? { progressAt: latestRelatedAt, evidence: "work-review-integration" }
|
|
264
|
+
: {
|
|
265
|
+
progressAt: baseline,
|
|
266
|
+
...(latestRelatedAt === undefined ? {} : { evidence: "work-review-integration" })
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
/** Most recent createdAt of a Run-scoped event of one type, if any. */
|
|
270
|
+
export function latestRunEventTime(events, type, runId) {
|
|
271
|
+
let latest;
|
|
272
|
+
for (const event of events) {
|
|
273
|
+
if (event.type !== type || event.payload.runId !== runId)
|
|
274
|
+
continue;
|
|
275
|
+
if (latest === undefined || Date.parse(event.createdAt) > Date.parse(latest)) {
|
|
276
|
+
latest = event.createdAt;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
return latest;
|
|
280
|
+
}
|
|
281
|
+
/** Most recent non-control event carrying a Run identity. */
|
|
282
|
+
export function latestRunActivityAt(events, runId) {
|
|
283
|
+
const semanticTypes = new Set([
|
|
284
|
+
RUN_PROGRESS_EVENT,
|
|
285
|
+
"runtime.provider-turn-progress",
|
|
286
|
+
"message.sent",
|
|
287
|
+
"input.answered",
|
|
288
|
+
"input.auto-answered",
|
|
289
|
+
"input.cancelled",
|
|
290
|
+
"work.accepted",
|
|
291
|
+
"work.updated",
|
|
292
|
+
"review.completed",
|
|
293
|
+
"review.failed",
|
|
294
|
+
"integration.updated",
|
|
295
|
+
"integration.completed",
|
|
296
|
+
"integration.failed"
|
|
297
|
+
]);
|
|
298
|
+
let latest;
|
|
299
|
+
for (const event of events) {
|
|
300
|
+
if (!semanticTypes.has(event.type)
|
|
301
|
+
|| event.payload.runId !== runId
|
|
302
|
+
|| event.type === RUN_STALLED_EVENT
|
|
303
|
+
|| event.type === RUN_RECOVERED_EVENT)
|
|
304
|
+
continue;
|
|
305
|
+
if (event.type === "runtime.provider-turn-progress"
|
|
306
|
+
&& (typeof event.payload.progressAt !== "string"
|
|
307
|
+
|| !Number.isFinite(Date.parse(event.payload.progressAt))))
|
|
308
|
+
continue;
|
|
309
|
+
const activityAt = (event.type === RUN_PROGRESS_EVENT
|
|
310
|
+
|| event.type === "runtime.provider-turn-progress")
|
|
311
|
+
&& typeof event.payload.progressAt === "string"
|
|
312
|
+
&& Number.isFinite(Date.parse(event.payload.progressAt))
|
|
313
|
+
? event.payload.progressAt
|
|
314
|
+
: event.createdAt;
|
|
315
|
+
if (latest === undefined || Date.parse(activityAt) > Date.parse(latest)) {
|
|
316
|
+
latest = activityAt;
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
return latest;
|
|
320
|
+
}
|
|
321
|
+
/** The progress point recorded by the most recent stall attention for a Run. */
|
|
322
|
+
export function latestStallProgressAt(events, runId) {
|
|
323
|
+
let latest;
|
|
324
|
+
for (const event of events) {
|
|
325
|
+
if (event.type !== RUN_STALLED_EVENT || event.payload.runId !== runId)
|
|
326
|
+
continue;
|
|
327
|
+
if (latest === undefined || Date.parse(event.createdAt) > Date.parse(latest.createdAt)) {
|
|
328
|
+
latest = event;
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
return latest?.payload.progressAt;
|
|
332
|
+
}
|
|
333
|
+
/** Latest stall episode identity used for source-idempotent attention. */
|
|
334
|
+
export function latestStallEvidenceKey(events, runId) {
|
|
335
|
+
let latest;
|
|
336
|
+
for (const event of events) {
|
|
337
|
+
if (event.type !== RUN_STALLED_EVENT || event.payload.runId !== runId)
|
|
338
|
+
continue;
|
|
339
|
+
if (latest === undefined || Date.parse(event.createdAt) > Date.parse(latest.createdAt)) {
|
|
340
|
+
latest = event;
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
if (latest?.payload.progressAt === undefined)
|
|
344
|
+
return undefined;
|
|
345
|
+
return {
|
|
346
|
+
progressAt: latest.payload.progressAt,
|
|
347
|
+
evidenceKey: latest.payload.evidenceKey ?? "live-pane-no-progress"
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
/**
|
|
351
|
+
* True while a Run remains in an unresolved stall episode: a stall was raised
|
|
352
|
+
* and no later checkpoint has advanced its durable progress since. This is the
|
|
353
|
+
* projection the runtime-health view reads to surface needs-attention.
|
|
354
|
+
*/
|
|
355
|
+
export function isRoleRunStalled(events, runId) {
|
|
356
|
+
const stalledAt = latestRunEventTime(events, RUN_STALLED_EVENT, runId);
|
|
357
|
+
if (stalledAt === undefined)
|
|
358
|
+
return false;
|
|
359
|
+
const stalled = [...events]
|
|
360
|
+
.filter((event) => event.type === RUN_STALLED_EVENT && event.payload.runId === runId)
|
|
361
|
+
.sort((left, right) => Date.parse(right.createdAt) - Date.parse(left.createdAt))[0];
|
|
362
|
+
const stalledProgressAt = typeof stalled?.payload.progressAt === "string"
|
|
363
|
+
&& Number.isFinite(Date.parse(stalled.payload.progressAt))
|
|
364
|
+
? stalled.payload.progressAt
|
|
365
|
+
: stalledAt;
|
|
366
|
+
const recoveredAt = latestRunEventTime(events, RUN_RECOVERED_EVENT, runId);
|
|
367
|
+
if (recoveredAt !== undefined && Date.parse(recoveredAt) > Date.parse(stalledAt)) {
|
|
368
|
+
return false;
|
|
369
|
+
}
|
|
370
|
+
const progressAt = latestRunProgressAt(events, runId);
|
|
371
|
+
return progressAt === undefined || Date.parse(progressAt) <= Date.parse(stalledProgressAt);
|
|
372
|
+
}
|
|
373
|
+
/**
|
|
374
|
+
* Low-frequency health pass for active Task Role Runs. An unaccepted Run is
|
|
375
|
+
* watched as delivery-stalled after the reasonable delivery window; an
|
|
376
|
+
* accepted Run enters the execution-stall candidate scan after ten minutes,
|
|
377
|
+
* while the actual no-progress threshold remains thirty minutes.
|
|
378
|
+
* Leader Runs are only persisted when classification reaches truly-stalled —
|
|
379
|
+
* healthy downstream work, open user input, and recent own progress remain
|
|
380
|
+
* structured waiting/working facts. No branch sends terminal bytes, retries,
|
|
381
|
+
* replaces a Session, or changes Run status.
|
|
382
|
+
*/
|
|
383
|
+
export async function reconcileStalledRoleRuns(store, delivery, now, selection, windowMs = DEFAULT_STALL_WINDOW_MS, liveStatuses, resourceEvidence, resourceSuppressionKeys) {
|
|
384
|
+
// Dirty mailbox passes are intentionally not a second scheduler. Full
|
|
385
|
+
// reconcile owns the all-active-Run scan; dirty passes may still route the
|
|
386
|
+
// existing mailbox work without manufacturing another episode.
|
|
387
|
+
if (selection !== undefined && !selection.full)
|
|
388
|
+
return [];
|
|
389
|
+
if (store.listEvents === undefined || store.recordRoleRunStall === undefined)
|
|
390
|
+
return [];
|
|
391
|
+
const candidates = selectedSchedulerTasks(store, selection).flatMap((task) => (task.status !== "active"
|
|
392
|
+
? []
|
|
393
|
+
: selectedSchedulerRoles(store, task.id, selection).flatMap((role) => {
|
|
394
|
+
const run = store.getActiveAgentRun(task.id, role.name);
|
|
395
|
+
if (run === null || run.status !== "active")
|
|
396
|
+
return [];
|
|
397
|
+
return [{
|
|
398
|
+
task,
|
|
399
|
+
role,
|
|
400
|
+
run,
|
|
401
|
+
session: store.getRoleSession(task.id, role.name, run.effective.agentId)
|
|
402
|
+
}];
|
|
403
|
+
})));
|
|
404
|
+
const stallCandidates = candidates.filter(({ run }) => isStallCandidate(run, now, windowMs));
|
|
405
|
+
if (stallCandidates.length === 0)
|
|
406
|
+
return [];
|
|
407
|
+
const stallCandidateKeys = new Set(stallCandidates.map(({ task, role }) => (`${task.id}\0${role.name}`)));
|
|
408
|
+
const observed = new Map();
|
|
409
|
+
// Recent active Runs are known healthy enough for Leader classification from
|
|
410
|
+
// their exact acceptance/creation boundary, but are deliberately not read
|
|
411
|
+
// from tmux or Event history until the 10-minute execution candidate window.
|
|
412
|
+
for (const candidate of candidates) {
|
|
413
|
+
const key = `${candidate.task.id}\0${candidate.role.name}`;
|
|
414
|
+
if (stallCandidateKeys.has(key))
|
|
415
|
+
continue;
|
|
416
|
+
observed.set(key, {
|
|
417
|
+
candidate,
|
|
418
|
+
live: liveStatuses?.get(key) ?? "present",
|
|
419
|
+
progressAt: candidate.run.deliveredAt ?? candidate.run.createdAt,
|
|
420
|
+
idleMs: 0,
|
|
421
|
+
stalled: false,
|
|
422
|
+
resourceActivity: false,
|
|
423
|
+
stallCandidate: false
|
|
424
|
+
});
|
|
425
|
+
}
|
|
426
|
+
let statuses = null;
|
|
427
|
+
if (liveStatuses !== undefined) {
|
|
428
|
+
statuses = null;
|
|
429
|
+
}
|
|
430
|
+
else if (delivery.inspectRoles !== undefined) {
|
|
431
|
+
try {
|
|
432
|
+
statuses = await delivery.inspectRoles(stallCandidates.map(({ task, role, run, session }) => ({
|
|
433
|
+
taskId: task.id,
|
|
434
|
+
roleName: role.name,
|
|
435
|
+
runId: run.id,
|
|
436
|
+
progressAt: run.deliveredAt ?? run.createdAt,
|
|
437
|
+
agentId: session?.agentId ?? run.effective.agentId,
|
|
438
|
+
adapterId: session?.adapterId ?? run.effective.adapterId,
|
|
439
|
+
...(session?.launchId === undefined ? {} : { launchId: session.launchId }),
|
|
440
|
+
...(session?.nativeSessionId === undefined
|
|
441
|
+
? {}
|
|
442
|
+
: { nativeSessionId: session.nativeSessionId })
|
|
443
|
+
})), stallCandidates.flatMap(({ task, role, run, session }) => (run.deliveredAt === undefined
|
|
444
|
+
? []
|
|
445
|
+
: [{
|
|
446
|
+
taskId: task.id,
|
|
447
|
+
roleName: role.name,
|
|
448
|
+
runId: run.id,
|
|
449
|
+
progressAt: currentRoleRunProgressAt(store, task.id, role.name, run).progressAt,
|
|
450
|
+
agentId: session?.agentId ?? run.effective.agentId,
|
|
451
|
+
adapterId: session?.adapterId ?? run.effective.adapterId,
|
|
452
|
+
...(session?.launchId === undefined ? {} : { launchId: session.launchId }),
|
|
453
|
+
...(session?.nativeSessionId === undefined
|
|
454
|
+
? {}
|
|
455
|
+
: { nativeSessionId: session.nativeSessionId })
|
|
456
|
+
}])));
|
|
457
|
+
}
|
|
458
|
+
catch {
|
|
459
|
+
// Health inspection is advisory. Unknown host state must leave the exact
|
|
460
|
+
// Run/Session fence untouched and let the next full pass retry.
|
|
461
|
+
return [];
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
let byRole = null;
|
|
465
|
+
if (statuses !== null) {
|
|
466
|
+
try {
|
|
467
|
+
byRole = exactLiveStatuses(statuses, stallCandidates);
|
|
468
|
+
}
|
|
469
|
+
catch {
|
|
470
|
+
// A malformed/partial provider snapshot is not evidence of a stall.
|
|
471
|
+
// Leave all fences untouched and let the next full pass retry.
|
|
472
|
+
return [];
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
const raised = [];
|
|
476
|
+
for (const candidate of stallCandidates) {
|
|
477
|
+
const key = `${candidate.task.id}\0${candidate.role.name}`;
|
|
478
|
+
let live;
|
|
479
|
+
try {
|
|
480
|
+
if (liveStatuses !== undefined && !liveStatuses.has(key))
|
|
481
|
+
return [];
|
|
482
|
+
live = liveStatuses !== undefined
|
|
483
|
+
? liveStatuses.get(key)
|
|
484
|
+
: byRole === null
|
|
485
|
+
? await delivery.inspectRole({
|
|
486
|
+
taskId: candidate.task.id,
|
|
487
|
+
roleName: candidate.role.name,
|
|
488
|
+
agentId: candidate.session?.agentId ?? candidate.run.effective.agentId,
|
|
489
|
+
adapterId: candidate.session?.adapterId ?? candidate.run.effective.adapterId,
|
|
490
|
+
...(candidate.session?.nativeSessionId === undefined
|
|
491
|
+
? {}
|
|
492
|
+
: { nativeSessionId: candidate.session.nativeSessionId })
|
|
493
|
+
})
|
|
494
|
+
: byRole.get(key);
|
|
495
|
+
}
|
|
496
|
+
catch {
|
|
497
|
+
// Without a complete live snapshot, especially for downstream Runs,
|
|
498
|
+
// Leader classification would be unsafe. Treat the whole advisory pass
|
|
499
|
+
// as unknown instead of escalating a false stall.
|
|
500
|
+
return [];
|
|
501
|
+
}
|
|
502
|
+
// A missing process is handled by the existing fenced liveness pass. This
|
|
503
|
+
// monitor must not race it or synthesize a second failure disposition.
|
|
504
|
+
if (live !== "present") {
|
|
505
|
+
observed.set(key, {
|
|
506
|
+
candidate,
|
|
507
|
+
live,
|
|
508
|
+
progressAt: candidate.run.createdAt,
|
|
509
|
+
idleMs: 0,
|
|
510
|
+
stalled: false,
|
|
511
|
+
resourceActivity: false,
|
|
512
|
+
stallCandidate: true
|
|
513
|
+
});
|
|
514
|
+
continue;
|
|
515
|
+
}
|
|
516
|
+
const events = store.listEvents(candidate.task.id);
|
|
517
|
+
// Before exact acceptance there is no execution progress clock. Keep the
|
|
518
|
+
// delivery watch anchored to the Run creation/transport boundary even if
|
|
519
|
+
// checkpoints, output, or related WorkItem/Review/Integration records are
|
|
520
|
+
// newer; those facts are useful evidence but cannot prove provider
|
|
521
|
+
// acceptance or reset delivery timeout. Once accepted, deliveredAt is the
|
|
522
|
+
// semantic baseline and the durable fold may advance it.
|
|
523
|
+
const progress = currentRoleRunProgressAt(store, candidate.task.id, candidate.role.name, candidate.run, events);
|
|
524
|
+
const progressAt = progress.progressAt;
|
|
525
|
+
const evaluation = evaluateRoleRunStall({
|
|
526
|
+
progressAt,
|
|
527
|
+
now,
|
|
528
|
+
windowMs,
|
|
529
|
+
lastAttentionProgressAt: latestStallProgressAt(events, candidate.run.id)
|
|
530
|
+
});
|
|
531
|
+
const runAgentId = candidate.run.effective.agentId;
|
|
532
|
+
const runAdapterId = candidate.run.effective.adapterId;
|
|
533
|
+
const sessionMatchesRun = candidate.session === null
|
|
534
|
+
|| (candidate.session.agentId === runAgentId
|
|
535
|
+
&& candidate.session.adapterId === runAdapterId);
|
|
536
|
+
const sessionUsable = candidate.session === null
|
|
537
|
+
|| (candidate.session.status !== "stopped" && candidate.session.status !== "broken");
|
|
538
|
+
const expectedResourceIdentity = candidate.session === null
|
|
539
|
+
? undefined
|
|
540
|
+
: {
|
|
541
|
+
taskId: candidate.task.id,
|
|
542
|
+
roleName: candidate.role.name,
|
|
543
|
+
runId: candidate.run.id,
|
|
544
|
+
agentId: runAgentId,
|
|
545
|
+
adapterId: runAdapterId,
|
|
546
|
+
...(candidate.session.nativeSessionId === undefined
|
|
547
|
+
? {}
|
|
548
|
+
: { nativeSessionId: candidate.session.nativeSessionId }),
|
|
549
|
+
...(candidate.session.launchId === undefined
|
|
550
|
+
? {}
|
|
551
|
+
: { launchId: candidate.session.launchId })
|
|
552
|
+
};
|
|
553
|
+
const resourceSnapshot = resourceForRun(resourceEvidence, candidate.task.id, candidate.role.name, candidate.run.id);
|
|
554
|
+
const resourceCanSuppress = live === "present"
|
|
555
|
+
&& sessionMatchesRun
|
|
556
|
+
&& sessionUsable
|
|
557
|
+
&& expectedResourceIdentity !== undefined
|
|
558
|
+
&& resourceEvidenceMatchesCurrentRun(resourceSnapshot, expectedResourceIdentity, progressAt)
|
|
559
|
+
&& resourceEvidenceIsFresh(resourceSnapshot, now, windowMs);
|
|
560
|
+
const resource = resourceCanSuppress
|
|
561
|
+
? await consumeResourceEvidence(store, resourceEvidence, candidate.task.id, candidate.role.name, candidate.run.id, progressAt, expectedResourceIdentity, resourceSuppressionKeys, now)
|
|
562
|
+
: undefined;
|
|
563
|
+
const health = projectRoleRunHealth({
|
|
564
|
+
progressAt,
|
|
565
|
+
createdAt: candidate.run.createdAt,
|
|
566
|
+
...(candidate.run.deliveredAt === undefined
|
|
567
|
+
? {}
|
|
568
|
+
: { deliveredAt: candidate.run.deliveredAt }),
|
|
569
|
+
now,
|
|
570
|
+
windowMs,
|
|
571
|
+
hostLiveness: live,
|
|
572
|
+
nativeSession: candidate.session,
|
|
573
|
+
providerAcceptance: candidate.run.deliveredAt === undefined
|
|
574
|
+
? "ambiguous"
|
|
575
|
+
: "accepted",
|
|
576
|
+
resource,
|
|
577
|
+
roleName: candidate.role.name
|
|
578
|
+
});
|
|
579
|
+
// Delivery-stalled Runs retain the existing delivery clock and immediate
|
|
580
|
+
// provider-uncertainty path. Accepted execution Runs use the shared
|
|
581
|
+
// projection, including its exact Session/host and advisory resource
|
|
582
|
+
// conditions.
|
|
583
|
+
const resourceActivity = health.resourceActivity;
|
|
584
|
+
const stalled = candidate.run.deliveredAt === undefined
|
|
585
|
+
? evaluation.stalled && live === "present"
|
|
586
|
+
: health.stalled && sessionMatchesRun && sessionUsable;
|
|
587
|
+
observed.set(key, {
|
|
588
|
+
candidate,
|
|
589
|
+
live,
|
|
590
|
+
progressAt,
|
|
591
|
+
idleMs: evaluation.idleMs,
|
|
592
|
+
// Resource activity is advisory: with a live, matching native Session it
|
|
593
|
+
// keeps a long inference/remote-IO Run in the working projection, but it
|
|
594
|
+
// never advances progress or creates a recovered event by itself.
|
|
595
|
+
stalled,
|
|
596
|
+
resourceActivity,
|
|
597
|
+
evidence: [
|
|
598
|
+
...(progress.evidence === undefined ? [] : [progress.evidence]),
|
|
599
|
+
...(resourceActivity ? ["resource-activity"] : [])
|
|
600
|
+
].join(",") || undefined,
|
|
601
|
+
stallCandidate: true
|
|
602
|
+
});
|
|
603
|
+
}
|
|
604
|
+
for (const current of observed.values()) {
|
|
605
|
+
if (current.live !== "present" || !current.stalled || !current.stallCandidate)
|
|
606
|
+
continue;
|
|
607
|
+
const { candidate, progressAt } = current;
|
|
608
|
+
const events = store.listEvents(candidate.task.id);
|
|
609
|
+
const previous = latestStallEvidenceKey(events, candidate.run.id);
|
|
610
|
+
if (previous !== undefined
|
|
611
|
+
&& Date.parse(progressAt) > Date.parse(previous.progressAt)) {
|
|
612
|
+
// A new semantic progress point closes the previous episode first. It
|
|
613
|
+
// may itself already be older than the window, in which case the same
|
|
614
|
+
// pass records the next Run+progressAt episode below.
|
|
615
|
+
store.recordRoleRunProgress?.({
|
|
616
|
+
taskId: candidate.task.id,
|
|
617
|
+
roleName: candidate.role.name,
|
|
618
|
+
runId: candidate.run.id,
|
|
619
|
+
progressAt,
|
|
620
|
+
...(current.evidence === undefined ? {} : { evidence: current.evidence }),
|
|
621
|
+
now
|
|
622
|
+
});
|
|
623
|
+
}
|
|
624
|
+
const kind = candidate.run.deliveredAt === undefined
|
|
625
|
+
? "delivery-stalled"
|
|
626
|
+
: "execution-stalled";
|
|
627
|
+
const classification = candidate.role.name === "leader"
|
|
628
|
+
? classifyLeaderStall(store, candidate.task.id, observed, now, windowMs)
|
|
629
|
+
: "truly-stalled";
|
|
630
|
+
// A Leader waiting on the user or healthy downstream work is not a stall
|
|
631
|
+
// episode. Its durable InputRequest/worker facts remain the evidence.
|
|
632
|
+
if (classification !== "truly-stalled")
|
|
633
|
+
continue;
|
|
634
|
+
const evidenceKey = [
|
|
635
|
+
kind,
|
|
636
|
+
stallEvidenceKey(candidate.role.status, candidate.session?.status),
|
|
637
|
+
classification,
|
|
638
|
+
...(candidate.role.name === "leader"
|
|
639
|
+
? [leaderStallEvidence(store, candidate.task.id, observed, now, windowMs)]
|
|
640
|
+
: []),
|
|
641
|
+
...(current.evidence === undefined ? [] : [current.evidence])
|
|
642
|
+
].join(":");
|
|
643
|
+
// The final contract is one episode per Run + semantic progress point.
|
|
644
|
+
// New role/session/provider evidence is retained in the Leader's next
|
|
645
|
+
// diagnostic context, not duplicated as another Task-level alert.
|
|
646
|
+
if (previous?.progressAt === progressAt)
|
|
647
|
+
continue;
|
|
648
|
+
const persisted = store.recordRoleRunStall({
|
|
649
|
+
taskId: candidate.task.id,
|
|
650
|
+
roleName: candidate.role.name,
|
|
651
|
+
runId: candidate.run.id,
|
|
652
|
+
agentId: candidate.run.effective.agentId,
|
|
653
|
+
adapterId: candidate.run.effective.adapterId,
|
|
654
|
+
session: candidate.session,
|
|
655
|
+
kind,
|
|
656
|
+
classification,
|
|
657
|
+
progressAt,
|
|
658
|
+
idleMs: current.idleMs,
|
|
659
|
+
evidenceKey,
|
|
660
|
+
now
|
|
661
|
+
});
|
|
662
|
+
if (persisted === "raised" || persisted === "already-raised") {
|
|
663
|
+
raised.push({
|
|
664
|
+
taskId: candidate.task.id,
|
|
665
|
+
roleName: candidate.role.name,
|
|
666
|
+
runId: candidate.run.id,
|
|
667
|
+
status: persisted,
|
|
668
|
+
kind,
|
|
669
|
+
classification,
|
|
670
|
+
idleMs: current.idleMs
|
|
671
|
+
});
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
// A related WorkItem/Review/Integration fold may advance progress without
|
|
675
|
+
// carrying the Run id. Materialize it once so context/web can clear the
|
|
676
|
+
// attention projection and the event history records the recovery boundary.
|
|
677
|
+
for (const current of observed.values()) {
|
|
678
|
+
if (current.live !== "present" || current.stalled || !current.stallCandidate)
|
|
679
|
+
continue;
|
|
680
|
+
const { candidate, progressAt } = current;
|
|
681
|
+
const events = store.listEvents(candidate.task.id);
|
|
682
|
+
const previous = latestStallEvidenceKey(events, candidate.run.id);
|
|
683
|
+
if (previous !== undefined
|
|
684
|
+
&& Date.parse(progressAt) > Date.parse(previous.progressAt)) {
|
|
685
|
+
store.recordRoleRunProgress?.({
|
|
686
|
+
taskId: candidate.task.id,
|
|
687
|
+
roleName: candidate.role.name,
|
|
688
|
+
runId: candidate.run.id,
|
|
689
|
+
progressAt,
|
|
690
|
+
...(current.evidence === undefined ? {} : { evidence: current.evidence }),
|
|
691
|
+
now
|
|
692
|
+
});
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
return raised;
|
|
696
|
+
}
|
|
697
|
+
function isStallCandidate(run, now, windowMs) {
|
|
698
|
+
const startAt = run.deliveredAt ?? run.createdAt;
|
|
699
|
+
const ageMs = now.getTime() - Date.parse(startAt);
|
|
700
|
+
if (!Number.isFinite(ageMs))
|
|
701
|
+
return false;
|
|
702
|
+
// Undelivered Runs are watched for a delivery stall on the same reasonable
|
|
703
|
+
// window, but they never enter execution-stall candidate filtering.
|
|
704
|
+
return run.deliveredAt === undefined
|
|
705
|
+
? ageMs >= windowMs
|
|
706
|
+
: ageMs >= DEFAULT_EXECUTION_STALL_CANDIDATE_AGE_MS;
|
|
707
|
+
}
|
|
708
|
+
function classifyLeaderStall(store, taskId, observed, now, windowMs) {
|
|
709
|
+
if (store.hasOpenInputRequest(taskId))
|
|
710
|
+
return "waiting-user";
|
|
711
|
+
const downstream = [...observed.values()].filter((entry) => (entry.candidate.task.id === taskId && entry.candidate.role.name !== "leader"));
|
|
712
|
+
// A present downstream Run keeps recovery Leader-owned. If that Run is
|
|
713
|
+
// itself stalled, this pass has just routed its structured attention to the
|
|
714
|
+
// Leader; escalating the Leader to the Operator in the same pass would skip
|
|
715
|
+
// the intended recovery owner.
|
|
716
|
+
const downstreamPresent = downstream.some((entry) => entry.live === "present");
|
|
717
|
+
const leader = [...observed.values()].find((entry) => (entry.candidate.task.id === taskId && entry.candidate.role.name === "leader"));
|
|
718
|
+
const mailbox = store.getWorkMailbox({ kind: "role", taskId, roleName: "leader" });
|
|
719
|
+
const pending = mailbox?.pending;
|
|
720
|
+
const processing = mailbox?.processing;
|
|
721
|
+
const processingCurrent = processing?.executionRef?.type === "run"
|
|
722
|
+
&& processing.executionRef.taskId === taskId
|
|
723
|
+
&& leader !== undefined
|
|
724
|
+
&& processing.executionRef.id === leader.candidate.run.id;
|
|
725
|
+
const currentRunId = leader?.candidate.run.id;
|
|
726
|
+
if (processingCurrent && currentRunId !== undefined && processing !== null) {
|
|
727
|
+
const actionProgressAt = latestLeaderActionProgressAt(store, taskId, currentRunId, processing.startedAt, processing.batch, now);
|
|
728
|
+
const actionProgressMs = Date.parse(actionProgressAt);
|
|
729
|
+
const actionStalled = Number.isFinite(actionProgressMs)
|
|
730
|
+
&& now.getTime() - actionProgressMs >= windowMs;
|
|
731
|
+
if (actionStalled)
|
|
732
|
+
return "truly-stalled";
|
|
733
|
+
return downstreamPresent ? "waiting-on-workers" : "working";
|
|
734
|
+
}
|
|
735
|
+
const pendingStalled = pending !== null
|
|
736
|
+
&& pending !== undefined
|
|
737
|
+
&& Number.isFinite(Date.parse(pending.lastQueuedAt))
|
|
738
|
+
&& now.getTime() - Date.parse(pending.lastQueuedAt) >= windowMs;
|
|
739
|
+
if (pendingStalled)
|
|
740
|
+
return "truly-stalled";
|
|
741
|
+
if (downstreamPresent)
|
|
742
|
+
return "waiting-on-workers";
|
|
743
|
+
return "truly-stalled";
|
|
744
|
+
}
|
|
745
|
+
const LEADER_ACTION_PROGRESS_TYPES = new Set([
|
|
746
|
+
RUN_PROGRESS_EVENT,
|
|
747
|
+
"runtime.provider-turn-progress",
|
|
748
|
+
"input.answered",
|
|
749
|
+
"input.auto-answered",
|
|
750
|
+
"input.cancelled",
|
|
751
|
+
"work.accepted",
|
|
752
|
+
"work.updated",
|
|
753
|
+
"work.retired",
|
|
754
|
+
"review.completed",
|
|
755
|
+
"review.failed",
|
|
756
|
+
"integration.updated",
|
|
757
|
+
"integration.completed",
|
|
758
|
+
"integration.failed",
|
|
759
|
+
"decision.recorded",
|
|
760
|
+
"decision.superseded",
|
|
761
|
+
"milestone.added"
|
|
762
|
+
]);
|
|
763
|
+
function latestLeaderActionProgressAt(store, taskId, runId, startedAt, batch, now) {
|
|
764
|
+
const startedMs = Date.parse(startedAt);
|
|
765
|
+
if (!Number.isFinite(startedMs))
|
|
766
|
+
return "";
|
|
767
|
+
let latest = startedAt;
|
|
768
|
+
let latestMs = startedMs;
|
|
769
|
+
for (const event of store.listEvents?.(taskId) ?? []) {
|
|
770
|
+
if (!LEADER_ACTION_PROGRESS_TYPES.has(event.type))
|
|
771
|
+
continue;
|
|
772
|
+
if (event.payload.taskId !== undefined && event.payload.taskId !== taskId)
|
|
773
|
+
continue;
|
|
774
|
+
if (!leaderActionEventMatches(event, taskId, runId, batch))
|
|
775
|
+
continue;
|
|
776
|
+
const createdMs = Date.parse(event.createdAt);
|
|
777
|
+
if (!Number.isFinite(createdMs)
|
|
778
|
+
|| createdMs < startedMs
|
|
779
|
+
|| createdMs > now.getTime())
|
|
780
|
+
continue;
|
|
781
|
+
const value = (event.type === RUN_PROGRESS_EVENT
|
|
782
|
+
|| event.type === "runtime.provider-turn-progress")
|
|
783
|
+
&& typeof event.payload.progressAt === "string"
|
|
784
|
+
&& Number.isFinite(Date.parse(event.payload.progressAt))
|
|
785
|
+
? event.payload.progressAt
|
|
786
|
+
: event.createdAt;
|
|
787
|
+
const valueMs = Date.parse(value);
|
|
788
|
+
if (!Number.isFinite(valueMs)
|
|
789
|
+
|| valueMs < startedMs
|
|
790
|
+
|| valueMs > now.getTime()
|
|
791
|
+
|| valueMs <= latestMs)
|
|
792
|
+
continue;
|
|
793
|
+
latest = value;
|
|
794
|
+
latestMs = valueMs;
|
|
795
|
+
}
|
|
796
|
+
return latest;
|
|
797
|
+
}
|
|
798
|
+
function leaderActionEventMatches(event, taskId, runId, batch) {
|
|
799
|
+
if (event.taskId !== taskId)
|
|
800
|
+
return false;
|
|
801
|
+
if (event.payload.leaderRunId === runId)
|
|
802
|
+
return true;
|
|
803
|
+
if (event.payload.runId === runId)
|
|
804
|
+
return true;
|
|
805
|
+
const refs = batch.refs ?? [];
|
|
806
|
+
const refMatches = (type, payloadKey) => {
|
|
807
|
+
const id = event.payload[payloadKey];
|
|
808
|
+
return typeof id === "string" && refs.some((ref) => (ref.type === type
|
|
809
|
+
&& ref.id === id
|
|
810
|
+
&& (ref.taskId === undefined || ref.taskId === taskId)));
|
|
811
|
+
};
|
|
812
|
+
if (refMatches("run", "runId"))
|
|
813
|
+
return true;
|
|
814
|
+
if (refMatches("work-item", "workItemId"))
|
|
815
|
+
return true;
|
|
816
|
+
const reason = event.type.replaceAll(".", "-");
|
|
817
|
+
// Mailbox reasons are opaque exact coalescing keys. A prefix or suffix
|
|
818
|
+
// resemblance can belong to another action in the same Task and is not a
|
|
819
|
+
// durable ownership fence.
|
|
820
|
+
const reasonMatches = batch.reasons.some((candidate) => (candidate === reason));
|
|
821
|
+
return reasonMatches && refs.some((ref) => (ref.type === "task" && (ref.taskId === undefined || ref.taskId === taskId)));
|
|
822
|
+
}
|
|
823
|
+
function leaderStallEvidence(store, taskId, observed, now, windowMs) {
|
|
824
|
+
const downstream = [...observed.values()].filter((entry) => (entry.candidate.task.id === taskId && entry.candidate.role.name !== "leader"));
|
|
825
|
+
const active = downstream.filter((entry) => entry.candidate.run !== null).length;
|
|
826
|
+
const healthy = downstream.filter((entry) => entry.live === "present" && !entry.stalled).length;
|
|
827
|
+
const stalled = downstream.filter((entry) => entry.live === "present" && entry.stalled).length;
|
|
828
|
+
const pending = store.getWorkMailbox({ kind: "role", taskId, roleName: "leader" })?.pending;
|
|
829
|
+
const pendingAge = pending === undefined || pending === null
|
|
830
|
+
? "none"
|
|
831
|
+
: Number.isFinite(Date.parse(pending.lastQueuedAt))
|
|
832
|
+
&& now.getTime() - Date.parse(pending.lastQueuedAt) >= windowMs
|
|
833
|
+
? "stale"
|
|
834
|
+
: "recent";
|
|
835
|
+
const processing = store.getWorkMailbox({ kind: "role", taskId, roleName: "leader" })?.processing;
|
|
836
|
+
const leader = [...observed.values()].find((entry) => (entry.candidate.task.id === taskId && entry.candidate.role.name === "leader"));
|
|
837
|
+
const processingCurrent = processing?.executionRef?.type === "run"
|
|
838
|
+
&& processing.executionRef.taskId === taskId
|
|
839
|
+
&& leader !== undefined
|
|
840
|
+
&& processing.executionRef.id === leader.candidate.run.id;
|
|
841
|
+
const processingAge = processing === undefined || processing === null
|
|
842
|
+
? "none"
|
|
843
|
+
: !processingCurrent
|
|
844
|
+
? "mismatched"
|
|
845
|
+
: Number.isFinite(Date.parse(processing.startedAt))
|
|
846
|
+
&& now.getTime() - Date.parse(processing.startedAt) >= windowMs
|
|
847
|
+
? "stale"
|
|
848
|
+
: "recent";
|
|
849
|
+
return `downstream=active:${active},healthy:${healthy},stalled:${stalled}:leader-mailbox=${pendingAge},leader-processing=${processingAge}`;
|
|
850
|
+
}
|
|
851
|
+
function stallEvidenceKey(roleStatus, sessionStatus) {
|
|
852
|
+
return `live-pane-no-progress:role=${roleStatus}:session=${sessionStatus ?? "unknown"}`;
|
|
853
|
+
}
|
|
854
|
+
function exactLiveStatuses(statuses, candidates) {
|
|
855
|
+
const expected = new Set(candidates.map(({ task, role }) => `${task.id}\0${role.name}`));
|
|
856
|
+
const result = new Map();
|
|
857
|
+
for (const status of statuses) {
|
|
858
|
+
const key = `${status.taskId}\0${status.roleName}`;
|
|
859
|
+
if (!expected.has(key) || result.has(key)) {
|
|
860
|
+
throw new Error("Tmux Role stall snapshot is invalid.");
|
|
861
|
+
}
|
|
862
|
+
result.set(key, status.status);
|
|
863
|
+
}
|
|
864
|
+
if (result.size !== expected.size)
|
|
865
|
+
throw new Error("Tmux Role stall snapshot is incomplete.");
|
|
866
|
+
return result;
|
|
867
|
+
}
|
|
868
|
+
function resourceForRun(snapshot, taskId, roleName, runId) {
|
|
869
|
+
if (snapshot === undefined)
|
|
870
|
+
return undefined;
|
|
871
|
+
return snapshot.get(`${taskId}\0${roleName}\0${runId}`);
|
|
872
|
+
}
|
|
873
|
+
function resourceEvidenceMatchesCurrentRun(resource, expected, progressAt) {
|
|
874
|
+
if (resource === undefined || expected === undefined)
|
|
875
|
+
return false;
|
|
876
|
+
const identity = resource.identity;
|
|
877
|
+
if (identity === undefined
|
|
878
|
+
|| resource.progressAt !== progressAt
|
|
879
|
+
|| identity.taskId !== expected.taskId
|
|
880
|
+
|| identity.roleName !== expected.roleName
|
|
881
|
+
|| identity.runId !== expected.runId
|
|
882
|
+
|| identity.agentId !== expected.agentId
|
|
883
|
+
|| identity.adapterId !== expected.adapterId
|
|
884
|
+
|| identity.nativeSessionId !== expected.nativeSessionId
|
|
885
|
+
|| identity.launchId !== expected.launchId)
|
|
886
|
+
return false;
|
|
887
|
+
return hasResourceIdentityText(identity.nativeSessionId)
|
|
888
|
+
|| hasResourceIdentityText(identity.launchId);
|
|
889
|
+
}
|
|
890
|
+
function hasResourceIdentityText(value) {
|
|
891
|
+
return typeof value === "string" && value.trim().length > 0;
|
|
892
|
+
}
|
|
893
|
+
/** Persist the first advisory sample and keep it bounded by the semantic gap. */
|
|
894
|
+
async function consumeResourceEvidence(store, snapshot, taskId, roleName, runId, progressAt, expectedIdentity, suppressionKeys, now) {
|
|
895
|
+
const resource = resourceForRun(snapshot, taskId, roleName, runId);
|
|
896
|
+
if (resource === undefined)
|
|
897
|
+
return undefined;
|
|
898
|
+
if (!resourceEvidenceMatchesCurrentRun(resource, expectedIdentity, progressAt)) {
|
|
899
|
+
return resource;
|
|
900
|
+
}
|
|
901
|
+
if (resource.active !== true || resource.changed !== true)
|
|
902
|
+
return resource;
|
|
903
|
+
const key = `${taskId}\0${roleName}\0${runId}\0${progressAt}`;
|
|
904
|
+
if (store.recordRoleRunResourceSuppression !== undefined) {
|
|
905
|
+
const persisted = store.recordRoleRunResourceSuppression({
|
|
906
|
+
taskId,
|
|
907
|
+
roleName,
|
|
908
|
+
runId,
|
|
909
|
+
agentId: expectedIdentity.agentId,
|
|
910
|
+
adapterId: expectedIdentity.adapterId,
|
|
911
|
+
...(expectedIdentity.nativeSessionId === undefined
|
|
912
|
+
? {}
|
|
913
|
+
: { nativeSessionId: expectedIdentity.nativeSessionId }),
|
|
914
|
+
...(expectedIdentity.launchId === undefined
|
|
915
|
+
? {}
|
|
916
|
+
: { launchId: expectedIdentity.launchId }),
|
|
917
|
+
progressAt,
|
|
918
|
+
observedAt: resource.observedAt,
|
|
919
|
+
now
|
|
920
|
+
});
|
|
921
|
+
if (persisted === "recorded" || persisted === "already-recorded") {
|
|
922
|
+
return resourceFallsWithinSemanticGap(progressAt, now)
|
|
923
|
+
? resource
|
|
924
|
+
: { ...resource, active: false, changed: false };
|
|
925
|
+
}
|
|
926
|
+
// A concurrent Run/session change invalidates this sample. Do not let the
|
|
927
|
+
// stale changed bit suppress the current Run's attention episode.
|
|
928
|
+
return { ...resource, active: false, changed: false };
|
|
929
|
+
}
|
|
930
|
+
if (suppressionKeys === undefined || !suppressionKeys.has(key)) {
|
|
931
|
+
suppressionKeys?.add(key);
|
|
932
|
+
return resource;
|
|
933
|
+
}
|
|
934
|
+
return { ...resource, active: false, changed: false };
|
|
935
|
+
}
|
|
936
|
+
function resourceFallsWithinSemanticGap(progressAt, now) {
|
|
937
|
+
const progressMs = Date.parse(progressAt);
|
|
938
|
+
return Number.isFinite(progressMs)
|
|
939
|
+
&& progressMs <= now.getTime()
|
|
940
|
+
&& now.getTime() - progressMs < DEFAULT_RESOURCE_ONLY_SEMANTIC_GAP_MS;
|
|
941
|
+
}
|
|
942
|
+
function resourceEvidenceIsFresh(evidence, now, windowMs) {
|
|
943
|
+
if (evidence === undefined)
|
|
944
|
+
return false;
|
|
945
|
+
const observedAt = Date.parse(evidence.observedAt);
|
|
946
|
+
if (!Number.isFinite(observedAt))
|
|
947
|
+
return false;
|
|
948
|
+
// A sample from an earlier scheduler window is not a current health signal.
|
|
949
|
+
return observedAt <= now.getTime()
|
|
950
|
+
&& now.getTime() - observedAt < Math.max(windowMs, DEFAULT_EXECUTION_STALL_CANDIDATE_AGE_MS);
|
|
951
|
+
}
|