@the-open-engine/zeroshot 6.7.0 → 6.7.1
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/cli/commands/providers.js +4 -1
- package/cli/index.js +18 -12
- package/lib/agent-cli-provider/adapters/claude.d.ts.map +1 -1
- package/lib/agent-cli-provider/adapters/claude.js +23 -10
- package/lib/agent-cli-provider/adapters/claude.js.map +1 -1
- package/lib/agent-cli-provider/adapters/codex.d.ts.map +1 -1
- package/lib/agent-cli-provider/adapters/codex.js +4 -0
- package/lib/agent-cli-provider/adapters/codex.js.map +1 -1
- package/lib/agent-cli-provider/adapters/common.d.ts.map +1 -1
- package/lib/agent-cli-provider/adapters/common.js +2 -1
- package/lib/agent-cli-provider/adapters/common.js.map +1 -1
- package/lib/agent-cli-provider/contract-options.d.ts.map +1 -1
- package/lib/agent-cli-provider/contract-options.js +2 -1
- package/lib/agent-cli-provider/contract-options.js.map +1 -1
- package/lib/agent-cli-provider/index.d.ts +1 -1
- package/lib/agent-cli-provider/index.d.ts.map +1 -1
- package/lib/agent-cli-provider/index.js.map +1 -1
- package/lib/agent-cli-provider/provider-registry.d.ts +1 -1
- package/lib/agent-cli-provider/provider-registry.js +1 -1
- package/lib/agent-cli-provider/provider-registry.js.map +1 -1
- package/lib/agent-cli-provider/single-agent-runtime.js +6 -2
- package/lib/agent-cli-provider/single-agent-runtime.js.map +1 -1
- package/lib/agent-cli-provider/types.d.ts +3 -1
- package/lib/agent-cli-provider/types.d.ts.map +1 -1
- package/lib/agent-cli-provider/types.js.map +1 -1
- package/lib/settings.js +1 -0
- package/package.json +1 -1
- package/scripts/run-lint-staged-no-stash.js +68 -0
- package/src/agent/agent-lifecycle.js +368 -91
- package/src/agent/agent-task-executor.js +384 -101
- package/src/agent-cli-provider/adapters/claude.ts +29 -11
- package/src/agent-cli-provider/adapters/codex.ts +4 -0
- package/src/agent-cli-provider/adapters/common.ts +2 -1
- package/src/agent-cli-provider/contract-options.ts +2 -1
- package/src/agent-cli-provider/index.ts +1 -0
- package/src/agent-cli-provider/provider-registry.ts +1 -1
- package/src/agent-cli-provider/single-agent-runtime.ts +8 -2
- package/src/agent-cli-provider/types.ts +3 -1
- package/src/agent-wrapper.js +9 -2
- package/src/config-validator.js +10 -11
- package/src/orchestrator.js +18 -6
- package/task-lib/attachable-watcher.js +10 -1
- package/task-lib/commands/kill.js +34 -9
- package/task-lib/commands/status.js +13 -3
- package/task-lib/process-termination.js +202 -0
- package/task-lib/runner.js +8 -20
- package/task-lib/store.js +28 -6
- package/task-lib/watcher.js +14 -2
|
@@ -4,7 +4,6 @@ import {
|
|
|
4
4
|
type ClaudeCliFeatures,
|
|
5
5
|
type CommandSpec,
|
|
6
6
|
type ErrorClassification,
|
|
7
|
-
InvalidProviderModelError,
|
|
8
7
|
type LevelModelSpec,
|
|
9
8
|
type LevelOverrides,
|
|
10
9
|
type ModelCatalogEntry,
|
|
@@ -28,8 +27,23 @@ import { parseClaudeEvent } from './claude-parser';
|
|
|
28
27
|
|
|
29
28
|
const MODEL_CATALOG: Readonly<Record<string, ModelCatalogEntry>> = {
|
|
30
29
|
haiku: { rank: 1 },
|
|
30
|
+
'claude-haiku-4-5': { rank: 1 },
|
|
31
|
+
'claude-haiku-4-5-20251001': { rank: 1 },
|
|
31
32
|
sonnet: { rank: 2 },
|
|
33
|
+
'claude-sonnet-4-5': { rank: 2 },
|
|
34
|
+
'claude-sonnet-4-5-20250929': { rank: 2 },
|
|
35
|
+
'claude-sonnet-4-6': { rank: 2 },
|
|
36
|
+
'claude-sonnet-5': { rank: 2 },
|
|
32
37
|
opus: { rank: 3 },
|
|
38
|
+
'claude-opus-4-5': { rank: 3 },
|
|
39
|
+
'claude-opus-4-5-20251101': { rank: 3 },
|
|
40
|
+
'claude-opus-4-6': { rank: 3 },
|
|
41
|
+
'claude-opus-4-7': { rank: 3 },
|
|
42
|
+
'claude-opus-4-8': { rank: 3 },
|
|
43
|
+
fable: { rank: 3 },
|
|
44
|
+
'claude-fable-5': { rank: 3 },
|
|
45
|
+
'claude-mythos-5': { rank: 3 },
|
|
46
|
+
'claude-mythos-preview': { rank: 3 },
|
|
33
47
|
};
|
|
34
48
|
|
|
35
49
|
const LEVEL_MAPPING: Readonly<Record<ModelLevel, LevelModelSpec>> = {
|
|
@@ -50,6 +64,7 @@ function detectCliFeatures(helpText?: string | null): ClaudeCliFeatures {
|
|
|
50
64
|
supportsIncludePartials: unknown ? true : /--include-partial-messages/.test(help),
|
|
51
65
|
supportsVerbose: unknown ? true : /--verbose/.test(help),
|
|
52
66
|
supportsModel: unknown ? true : /--model/.test(help),
|
|
67
|
+
supportsEffort: unknown ? true : /--effort/.test(help),
|
|
53
68
|
unknown,
|
|
54
69
|
};
|
|
55
70
|
}
|
|
@@ -86,6 +101,9 @@ function addModelArgs(args: string[], options: BuildProviderCommandOptions): voi
|
|
|
86
101
|
if (options.modelSpec?.model && features.supportsModel !== false) {
|
|
87
102
|
args.push('--model', options.modelSpec.model);
|
|
88
103
|
}
|
|
104
|
+
if (options.modelSpec?.reasoningEffort && features.supportsEffort !== false) {
|
|
105
|
+
args.push('--effort', options.modelSpec.reasoningEffort);
|
|
106
|
+
}
|
|
89
107
|
}
|
|
90
108
|
|
|
91
109
|
function addAutoApproveArgs(args: string[], options: BuildProviderCommandOptions): void {
|
|
@@ -139,6 +157,15 @@ function collectWarnings(options: BuildProviderCommandOptions): WarningMetadata[
|
|
|
139
157
|
)
|
|
140
158
|
);
|
|
141
159
|
}
|
|
160
|
+
if (options.modelSpec?.reasoningEffort && features.supportsEffort === false) {
|
|
161
|
+
warnings.push(
|
|
162
|
+
warning(
|
|
163
|
+
'claude',
|
|
164
|
+
'claude-reasoning',
|
|
165
|
+
'Claude CLI does not support --effort; skipping reasoningEffort.'
|
|
166
|
+
)
|
|
167
|
+
);
|
|
168
|
+
}
|
|
142
169
|
return warnings;
|
|
143
170
|
}
|
|
144
171
|
|
|
@@ -176,16 +203,7 @@ function resolveModelSpec(level: ModelLevel, overrides?: LevelOverrides): Resolv
|
|
|
176
203
|
}
|
|
177
204
|
|
|
178
205
|
function validateModelId(modelId: string | null | undefined): string | null | undefined {
|
|
179
|
-
|
|
180
|
-
return validateModelIdFromCatalog('claude', MODEL_CATALOG, modelId);
|
|
181
|
-
} catch (error) {
|
|
182
|
-
if (modelId && (modelId === 'opus-4.6' || modelId === 'claude-opus-4-6')) {
|
|
183
|
-
throw new InvalidProviderModelError(
|
|
184
|
-
`Invalid model "${modelId}" for provider "claude". Use canonical model ids: haiku, sonnet, opus.`
|
|
185
|
-
);
|
|
186
|
-
}
|
|
187
|
-
throw error;
|
|
188
|
-
}
|
|
206
|
+
return validateModelIdFromCatalog('claude', MODEL_CATALOG, modelId);
|
|
189
207
|
}
|
|
190
208
|
|
|
191
209
|
function classifyError(error: unknown): ErrorClassification {
|
|
@@ -27,6 +27,10 @@ import { parseCodexEvent } from './codex-parser';
|
|
|
27
27
|
const MODEL_CATALOG: Readonly<Record<string, ModelCatalogEntry>> = {
|
|
28
28
|
'gpt-5.4': { rank: 2 },
|
|
29
29
|
'gpt-5.5': { rank: 3 },
|
|
30
|
+
'gpt-5.6': { rank: 3 },
|
|
31
|
+
'gpt-5.6-sol': { rank: 3 },
|
|
32
|
+
'gpt-5.6-terra': { rank: 2 },
|
|
33
|
+
'gpt-5.6-luna': { rank: 1 },
|
|
30
34
|
};
|
|
31
35
|
|
|
32
36
|
const LEVEL_MAPPING: Readonly<Record<ModelLevel, LevelModelSpec>> = {
|
|
@@ -100,8 +100,9 @@ export function validateModelIdFromCatalog(
|
|
|
100
100
|
): string | null | undefined {
|
|
101
101
|
if (!modelId) return modelId;
|
|
102
102
|
if (catalog[modelId] !== undefined) return modelId;
|
|
103
|
+
const validModels = Object.keys(catalog).join(', ');
|
|
103
104
|
throw new InvalidProviderModelError(
|
|
104
|
-
`Invalid model "${modelId}" for provider "${provider}".
|
|
105
|
+
`Invalid model "${modelId}" for provider "${provider}". Valid models: ${validModels}.`
|
|
105
106
|
);
|
|
106
107
|
}
|
|
107
108
|
|
|
@@ -13,7 +13,7 @@ import type {
|
|
|
13
13
|
|
|
14
14
|
const OUTPUT_FORMATS: readonly OutputFormat[] = ['text', 'json', 'stream-json'];
|
|
15
15
|
const MODEL_LEVELS: readonly ModelLevel[] = ['level1', 'level2', 'level3'];
|
|
16
|
-
const REASONING_EFFORTS: readonly ReasoningEffort[] = ['low', 'medium', 'high', 'xhigh'];
|
|
16
|
+
const REASONING_EFFORTS: readonly ReasoningEffort[] = ['low', 'medium', 'high', 'xhigh', 'max'];
|
|
17
17
|
const CLI_FEATURE_FIELDS = [
|
|
18
18
|
'supportsOutputFormat',
|
|
19
19
|
'supportsStreamJson',
|
|
@@ -22,6 +22,7 @@ const CLI_FEATURE_FIELDS = [
|
|
|
22
22
|
'supportsIncludePartials',
|
|
23
23
|
'supportsVerbose',
|
|
24
24
|
'supportsModel',
|
|
25
|
+
'supportsEffort',
|
|
25
26
|
'supportsJson',
|
|
26
27
|
'supportsOutputSchema',
|
|
27
28
|
'supportsDir',
|
|
@@ -61,7 +61,7 @@ type MutableModelSpec = {
|
|
|
61
61
|
};
|
|
62
62
|
|
|
63
63
|
const MODEL_LEVELS: readonly ModelLevel[] = ['level1', 'level2', 'level3'];
|
|
64
|
-
const REASONING_EFFORTS: readonly ReasoningEffort[] = ['low', 'medium', 'high', 'xhigh'];
|
|
64
|
+
const REASONING_EFFORTS: readonly ReasoningEffort[] = ['low', 'medium', 'high', 'xhigh', 'max'];
|
|
65
65
|
const settingsModule: unknown = require('../../lib/settings');
|
|
66
66
|
const providerDetectionModule: unknown = require('../../lib/provider-detection');
|
|
67
67
|
const claudeAuthModule: unknown = require('../../lib/settings/claude-auth');
|
|
@@ -420,7 +420,13 @@ function optionalModelLevel(value: unknown, field: string): ModelLevel | undefin
|
|
|
420
420
|
|
|
421
421
|
function optionalReasoningEffort(value: unknown, field: string): ReasoningEffort | undefined {
|
|
422
422
|
if (value === undefined) return undefined;
|
|
423
|
-
if (
|
|
423
|
+
if (
|
|
424
|
+
value === 'low' ||
|
|
425
|
+
value === 'medium' ||
|
|
426
|
+
value === 'high' ||
|
|
427
|
+
value === 'xhigh' ||
|
|
428
|
+
value === 'max'
|
|
429
|
+
) {
|
|
424
430
|
return value;
|
|
425
431
|
}
|
|
426
432
|
throw new Error(`${field} must be one of: ${REASONING_EFFORTS.join(', ')}.`);
|
|
@@ -15,7 +15,7 @@ export type ProviderId = (typeof providerIds)[number];
|
|
|
15
15
|
export type ProviderAlias = (typeof providerAliases)[number];
|
|
16
16
|
export type KnownProviderName = ProviderId | ProviderAlias;
|
|
17
17
|
export type ModelLevel = 'level1' | 'level2' | 'level3';
|
|
18
|
-
export type ReasoningEffort = 'low' | 'medium' | 'high' | 'xhigh';
|
|
18
|
+
export type ReasoningEffort = 'low' | 'medium' | 'high' | 'xhigh' | 'max';
|
|
19
19
|
export type OutputFormat = 'text' | 'json' | 'stream-json';
|
|
20
20
|
export type {
|
|
21
21
|
ProviderCapabilities,
|
|
@@ -95,6 +95,7 @@ export interface ClaudeCliFeatures extends BaseCliFeatures {
|
|
|
95
95
|
readonly supportsIncludePartials: boolean;
|
|
96
96
|
readonly supportsVerbose: boolean;
|
|
97
97
|
readonly supportsModel: boolean;
|
|
98
|
+
readonly supportsEffort: boolean;
|
|
98
99
|
}
|
|
99
100
|
|
|
100
101
|
export interface CodexCliFeatures extends BaseCliFeatures {
|
|
@@ -186,6 +187,7 @@ export interface CliFeatureOverrides {
|
|
|
186
187
|
readonly supportsIncludePartials?: boolean;
|
|
187
188
|
readonly supportsVerbose?: boolean;
|
|
188
189
|
readonly supportsModel?: boolean;
|
|
190
|
+
readonly supportsEffort?: boolean;
|
|
189
191
|
readonly supportsJson?: boolean;
|
|
190
192
|
readonly supportsOutputSchema?: boolean;
|
|
191
193
|
readonly supportsDir?: boolean;
|
package/src/agent-wrapper.js
CHANGED
|
@@ -81,8 +81,15 @@ class AgentWrapper {
|
|
|
81
81
|
// LIVENESS DETECTION - Track output freshness to detect stuck agents
|
|
82
82
|
/** @type {number | null} */
|
|
83
83
|
this.lastOutputTime = null; // Timestamp of last output received
|
|
84
|
+
/** @type {number | null} */
|
|
85
|
+
this.taskStartedAt = null; // Timestamp used for absolute task timeout
|
|
84
86
|
/** @type {NodeJS.Timeout | null} */
|
|
85
87
|
this.livenessCheckInterval = null; // Interval for health checks
|
|
88
|
+
this.consecutiveStaleWarnings = 0;
|
|
89
|
+
this.livenessTerminationStarted = false;
|
|
90
|
+
this.livenessTerminationContext = null;
|
|
91
|
+
this.livenessTerminationAttempts = 0;
|
|
92
|
+
this.livenessTerminationRetryAt = 0;
|
|
86
93
|
this.staleDuration = normalizedConfig.staleDuration;
|
|
87
94
|
this.enableLivenessCheck = normalizedConfig.enableLivenessCheck;
|
|
88
95
|
|
|
@@ -499,8 +506,8 @@ class AgentWrapper {
|
|
|
499
506
|
* Kill current task
|
|
500
507
|
* @private
|
|
501
508
|
*/
|
|
502
|
-
_killTask() {
|
|
503
|
-
return killTask(this);
|
|
509
|
+
_killTask(reason) {
|
|
510
|
+
return killTask(this, reason);
|
|
504
511
|
}
|
|
505
512
|
|
|
506
513
|
/**
|
package/src/config-validator.js
CHANGED
|
@@ -21,6 +21,9 @@ const { getProvider } = require('./providers');
|
|
|
21
21
|
const { CAPABILITIES } = require('./providers/capabilities');
|
|
22
22
|
const { GUIDANCE_TOPICS } = require('./guidance-topics');
|
|
23
23
|
|
|
24
|
+
const REASONING_EFFORTS = Object.freeze(['low', 'medium', 'high', 'xhigh', 'max']);
|
|
25
|
+
const REASONING_EFFORT_LABEL = REASONING_EFFORTS.join('|');
|
|
26
|
+
|
|
24
27
|
const HOOK_ACTION_TOPIC_CONTRACTS = Object.freeze({
|
|
25
28
|
verify_pull_request: Object.freeze([
|
|
26
29
|
{ topic: 'CLUSTER_COMPLETE', keys: null },
|
|
@@ -2054,14 +2057,13 @@ function validateProviderSettings(provider, providerSettings) {
|
|
|
2054
2057
|
providerModule.validateModelId(override.model);
|
|
2055
2058
|
}
|
|
2056
2059
|
if (override?.reasoningEffort && !providerSupportsCapability(provider, 'reasoningEffort')) {
|
|
2057
|
-
throw new Error(
|
|
2060
|
+
throw new Error(
|
|
2061
|
+
`reasoningEffort overrides are only supported for Claude, Codex, and Opencode`
|
|
2062
|
+
);
|
|
2058
2063
|
}
|
|
2059
|
-
if (
|
|
2060
|
-
override?.reasoningEffort &&
|
|
2061
|
-
!['low', 'medium', 'high', 'xhigh'].includes(override.reasoningEffort)
|
|
2062
|
-
) {
|
|
2064
|
+
if (override?.reasoningEffort && !REASONING_EFFORTS.includes(override.reasoningEffort)) {
|
|
2063
2065
|
throw new Error(
|
|
2064
|
-
`Invalid reasoningEffort "${override.reasoningEffort}" (
|
|
2066
|
+
`Invalid reasoningEffort "${override.reasoningEffort}" (${REASONING_EFFORT_LABEL})`
|
|
2065
2067
|
);
|
|
2066
2068
|
}
|
|
2067
2069
|
}
|
|
@@ -2172,12 +2174,9 @@ function validateModelRulesSupport(agent, provider, providerModule, levels, warn
|
|
|
2172
2174
|
function validateReasoningEffortSupport(agent, provider, warnings) {
|
|
2173
2175
|
if (agent.reasoningEffort && !providerSupportsCapability(provider, 'reasoningEffort')) {
|
|
2174
2176
|
warnings.push(`Agent "${agent.id}" sets reasoningEffort but ${provider} does not support it`);
|
|
2175
|
-
} else if (
|
|
2176
|
-
agent.reasoningEffort &&
|
|
2177
|
-
!['low', 'medium', 'high', 'xhigh'].includes(agent.reasoningEffort)
|
|
2178
|
-
) {
|
|
2177
|
+
} else if (agent.reasoningEffort && !REASONING_EFFORTS.includes(agent.reasoningEffort)) {
|
|
2179
2178
|
warnings.push(
|
|
2180
|
-
`Agent "${agent.id}" has invalid reasoningEffort "${agent.reasoningEffort}" (
|
|
2179
|
+
`Agent "${agent.id}" has invalid reasoningEffort "${agent.reasoningEffort}" (${REASONING_EFFORT_LABEL})`
|
|
2181
2180
|
);
|
|
2182
2181
|
}
|
|
2183
2182
|
}
|
package/src/orchestrator.js
CHANGED
|
@@ -1535,6 +1535,7 @@ class Orchestrator {
|
|
|
1535
1535
|
const agentRole = message.content?.data?.role;
|
|
1536
1536
|
const attempts = message.content?.data?.attempts || 1;
|
|
1537
1537
|
const hookFailure = message.content?.data?.hookFailure === true;
|
|
1538
|
+
const restartExhausted = message.content?.data?.restartExhausted === true;
|
|
1538
1539
|
|
|
1539
1540
|
await this._saveClusters();
|
|
1540
1541
|
|
|
@@ -1542,7 +1543,7 @@ class Orchestrator {
|
|
|
1542
1543
|
agentRole === 'implementation' ||
|
|
1543
1544
|
agentRole === 'coordinator' ||
|
|
1544
1545
|
message.sender === 'consensus-coordinator';
|
|
1545
|
-
const shouldStop = shouldStopForRole && (hookFailure || attempts >= 3);
|
|
1546
|
+
const shouldStop = shouldStopForRole && (hookFailure || restartExhausted || attempts >= 3);
|
|
1546
1547
|
|
|
1547
1548
|
if (shouldStop) {
|
|
1548
1549
|
this._log(`\n${'='.repeat(80)}`);
|
|
@@ -1551,6 +1552,7 @@ class Orchestrator {
|
|
|
1551
1552
|
this._log(
|
|
1552
1553
|
`${message.sender} (${agentRole || 'unknown role'}) failed` +
|
|
1553
1554
|
(hookFailure ? ` (hookFailure=true)` : ``) +
|
|
1555
|
+
(restartExhausted ? ` (restartExhausted=true)` : ``) +
|
|
1554
1556
|
` after ${attempts} attempts`
|
|
1555
1557
|
);
|
|
1556
1558
|
this._log(`Error: ${message.content?.data?.error || 'unknown'}`);
|
|
@@ -1573,7 +1575,7 @@ class Orchestrator {
|
|
|
1573
1575
|
});
|
|
1574
1576
|
}
|
|
1575
1577
|
|
|
1576
|
-
_registerAgentLifecycleHandlers(messageBus,
|
|
1578
|
+
_registerAgentLifecycleHandlers(messageBus, _clusterId) {
|
|
1577
1579
|
messageBus.on('topic:AGENT_LIFECYCLE', async (message) => {
|
|
1578
1580
|
const event = message.content?.data?.event;
|
|
1579
1581
|
if (
|
|
@@ -1596,13 +1598,14 @@ class Orchestrator {
|
|
|
1596
1598
|
const timeSinceLastOutput = message.content?.data?.timeSinceLastOutput;
|
|
1597
1599
|
const analysis = message.content?.data?.analysis || 'No analysis available';
|
|
1598
1600
|
|
|
1601
|
+
const consecutiveWarnings = message.content?.data?.consecutiveWarnings;
|
|
1602
|
+
const warningsBeforeKill = message.content?.data?.warningsBeforeKill;
|
|
1599
1603
|
this._log(
|
|
1600
|
-
`⚠️ Orchestrator: Agent ${agentId}
|
|
1604
|
+
`⚠️ Orchestrator: Agent ${agentId} has produced no output for ${Math.round(timeSinceLastOutput / 1000)}s ` +
|
|
1605
|
+
`(warning ${consecutiveWarnings}/${warningsBeforeKill})`
|
|
1601
1606
|
);
|
|
1602
1607
|
this._log(` Analysis: ${analysis}`);
|
|
1603
|
-
this._log(
|
|
1604
|
-
` Manual intervention may be needed - use 'zeroshot resume ${clusterId}' if stuck`
|
|
1605
|
-
);
|
|
1608
|
+
this._log(` Zeroshot will terminate and restart the task if inactivity persists`);
|
|
1606
1609
|
});
|
|
1607
1610
|
}
|
|
1608
1611
|
|
|
@@ -2361,6 +2364,15 @@ class Orchestrator {
|
|
|
2361
2364
|
return;
|
|
2362
2365
|
}
|
|
2363
2366
|
this.closed = true;
|
|
2367
|
+
|
|
2368
|
+
for (const cluster of this.clusters.values()) {
|
|
2369
|
+
if (typeof cluster.snapshotter?.stop === 'function') {
|
|
2370
|
+
cluster.snapshotter.stop();
|
|
2371
|
+
}
|
|
2372
|
+
if (typeof cluster.messageBus?.close === 'function') {
|
|
2373
|
+
cluster.messageBus.close();
|
|
2374
|
+
}
|
|
2375
|
+
}
|
|
2364
2376
|
}
|
|
2365
2377
|
|
|
2366
2378
|
/**
|
|
@@ -319,6 +319,8 @@ server.on('exit', async ({ exitCode, signal }) => {
|
|
|
319
319
|
try {
|
|
320
320
|
await updateTask(taskId, {
|
|
321
321
|
status,
|
|
322
|
+
pid: null,
|
|
323
|
+
processGroupId: null,
|
|
322
324
|
exitCode: resolvedCode,
|
|
323
325
|
error: fatalError || (resolvedCode !== 0 && signal ? `Killed by ${signal}` : null),
|
|
324
326
|
socketPath: null,
|
|
@@ -336,7 +338,12 @@ server.on('error', async (err) => {
|
|
|
336
338
|
log(`\nError: ${err.message}\n`);
|
|
337
339
|
await cleanupCommandSpec();
|
|
338
340
|
try {
|
|
339
|
-
await updateTask(taskId, {
|
|
341
|
+
await updateTask(taskId, {
|
|
342
|
+
status: 'failed',
|
|
343
|
+
pid: null,
|
|
344
|
+
processGroupId: null,
|
|
345
|
+
error: err.message,
|
|
346
|
+
});
|
|
340
347
|
} catch (updateError) {
|
|
341
348
|
log(`[${Date.now()}][ERROR] Failed to update task status: ${updateError.message}\n`);
|
|
342
349
|
}
|
|
@@ -358,6 +365,8 @@ try {
|
|
|
358
365
|
pid: server.pid,
|
|
359
366
|
socketPath,
|
|
360
367
|
attachable: true,
|
|
368
|
+
processGroupId: process.platform === 'win32' ? null : server.pid,
|
|
369
|
+
terminationStrategy: process.platform === 'win32' ? 'process-tree' : 'process-group',
|
|
361
370
|
});
|
|
362
371
|
|
|
363
372
|
log(`[${Date.now()}][SYSTEM] Started with PTY (attachable)\n`);
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import chalk from 'chalk';
|
|
2
2
|
import { getTask, updateTask } from '../store.js';
|
|
3
|
-
import {
|
|
3
|
+
import { terminateProcess } from '../runner.js';
|
|
4
4
|
|
|
5
|
-
export function killTaskCommand(taskId) {
|
|
5
|
+
export async function killTaskCommand(taskId, options = {}) {
|
|
6
6
|
const task = getTask(taskId);
|
|
7
7
|
|
|
8
8
|
if (!task) {
|
|
@@ -15,18 +15,43 @@ export function killTaskCommand(taskId) {
|
|
|
15
15
|
return;
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
-
|
|
18
|
+
const terminationOptions = {
|
|
19
|
+
...options,
|
|
20
|
+
processGroupId: task.processGroupId,
|
|
21
|
+
terminationStrategy: task.terminationStrategy || 'process',
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const result = await terminateProcess(task.pid, terminationOptions);
|
|
25
|
+
|
|
26
|
+
if (result.terminated && result.alreadyDead) {
|
|
19
27
|
console.log(chalk.yellow('Process already dead, updating status...'));
|
|
20
|
-
updateTask(taskId, {
|
|
28
|
+
updateTask(taskId, {
|
|
29
|
+
status: 'stale',
|
|
30
|
+
pid: null,
|
|
31
|
+
processGroupId: null,
|
|
32
|
+
error: 'Process died unexpectedly',
|
|
33
|
+
});
|
|
21
34
|
return;
|
|
22
35
|
}
|
|
23
36
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
37
|
+
if (result.terminated) {
|
|
38
|
+
const suffix = result.escalated ? ' after SIGKILL escalation' : ' with SIGTERM';
|
|
39
|
+
console.log(chalk.green(`✓ Killed task ${taskId} (PID: ${task.pid})${suffix}`));
|
|
40
|
+
if (result.degraded) {
|
|
41
|
+
console.log(chalk.yellow(`Warning: ${result.degradedReason}`));
|
|
42
|
+
}
|
|
43
|
+
updateTask(taskId, {
|
|
44
|
+
status: 'killed',
|
|
45
|
+
pid: null,
|
|
46
|
+
processGroupId: null,
|
|
47
|
+
exitCode: result.escalated ? 137 : 143,
|
|
48
|
+
error: result.escalated ? 'Killed by user after SIGKILL escalation' : 'Killed by user',
|
|
49
|
+
});
|
|
29
50
|
} else {
|
|
30
51
|
console.log(chalk.red(`Failed to kill task ${taskId}`));
|
|
52
|
+
updateTask(taskId, {
|
|
53
|
+
error: result.error || 'Process termination failed',
|
|
54
|
+
});
|
|
55
|
+
process.exitCode = 1;
|
|
31
56
|
}
|
|
32
57
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import chalk from 'chalk';
|
|
2
2
|
import { getTask } from '../store.js';
|
|
3
|
-
import {
|
|
3
|
+
import { isOwnedProcessTreeRunning } from '../runner.js';
|
|
4
4
|
|
|
5
5
|
export function showStatus(taskId) {
|
|
6
6
|
const task = getTask(taskId);
|
|
@@ -12,8 +12,18 @@ export function showStatus(taskId) {
|
|
|
12
12
|
|
|
13
13
|
// Verify running status
|
|
14
14
|
let status = task.status;
|
|
15
|
-
if (status === 'running'
|
|
16
|
-
|
|
15
|
+
if (status === 'running') {
|
|
16
|
+
try {
|
|
17
|
+
const running = isOwnedProcessTreeRunning(task.pid, {
|
|
18
|
+
processGroupId: task.processGroupId,
|
|
19
|
+
terminationStrategy: task.terminationStrategy || 'process',
|
|
20
|
+
});
|
|
21
|
+
if (!running) {
|
|
22
|
+
status = 'stale (process died)';
|
|
23
|
+
}
|
|
24
|
+
} catch (error) {
|
|
25
|
+
status = `stale (invalid process ownership: ${error.message})`;
|
|
26
|
+
}
|
|
17
27
|
}
|
|
18
28
|
|
|
19
29
|
const statusColor =
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import { execFile } from 'child_process';
|
|
2
|
+
import { promisify } from 'util';
|
|
3
|
+
|
|
4
|
+
const execFileAsync = promisify(execFile);
|
|
5
|
+
|
|
6
|
+
export function isProcessRunning(pid) {
|
|
7
|
+
if (!pid) return false;
|
|
8
|
+
try {
|
|
9
|
+
process.kill(pid, 0);
|
|
10
|
+
return true;
|
|
11
|
+
} catch {
|
|
12
|
+
return false;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function killTask(pid) {
|
|
17
|
+
if (!pid) return false;
|
|
18
|
+
try {
|
|
19
|
+
process.kill(pid, 'SIGTERM');
|
|
20
|
+
return true;
|
|
21
|
+
} catch {
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function sleep(ms) {
|
|
27
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function normalizeTerminationOwnership(pid, options) {
|
|
31
|
+
const terminationStrategy = options.terminationStrategy || 'process';
|
|
32
|
+
const processGroupId = Number(options.processGroupId) || null;
|
|
33
|
+
|
|
34
|
+
if (terminationStrategy === 'process-group') {
|
|
35
|
+
if (process.platform === 'win32') {
|
|
36
|
+
throw new Error(
|
|
37
|
+
'Process-group termination is unavailable on Windows; use terminationStrategy "process-tree"'
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
if (!processGroupId || processGroupId !== pid) {
|
|
41
|
+
throw new Error(
|
|
42
|
+
`Refusing process-group termination for PID ${pid}: owned processGroupId must equal the provider root PID`
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (terminationStrategy === 'process-tree' && process.platform !== 'win32') {
|
|
48
|
+
throw new Error(
|
|
49
|
+
`Process-tree termination is only supported on Windows; use terminationStrategy "process-group" on ${process.platform}`
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (!['process', 'process-group', 'process-tree'].includes(terminationStrategy)) {
|
|
54
|
+
throw new Error(`Unsupported termination strategy: ${terminationStrategy}`);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return { terminationStrategy, processGroupId };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function isProcessGroupRunning(processGroupId) {
|
|
61
|
+
try {
|
|
62
|
+
process.kill(-processGroupId, 0);
|
|
63
|
+
return true;
|
|
64
|
+
} catch (error) {
|
|
65
|
+
return error?.code === 'EPERM';
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function isOwnedProcessTreeRunning(pid, options = {}) {
|
|
70
|
+
if (!pid) return false;
|
|
71
|
+
const ownership = normalizeTerminationOwnership(pid, options);
|
|
72
|
+
if (ownership.terminationStrategy === 'process-group') {
|
|
73
|
+
return isProcessGroupRunning(ownership.processGroupId);
|
|
74
|
+
}
|
|
75
|
+
return isProcessRunning(pid);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function signalWindowsProcessTree(pid, force) {
|
|
79
|
+
const args = ['/PID', String(pid), '/T'];
|
|
80
|
+
if (force) args.push('/F');
|
|
81
|
+
await execFileAsync('taskkill', args, { windowsHide: true });
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function signalOwnedProcessTree(pid, signal, ownership) {
|
|
85
|
+
if (ownership.terminationStrategy === 'process-group') {
|
|
86
|
+
process.kill(-ownership.processGroupId, signal);
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
if (ownership.terminationStrategy === 'process-tree') {
|
|
90
|
+
await signalWindowsProcessTree(pid, signal === 'SIGKILL');
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
process.kill(pid, signal);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function waitForOwnedProcessTreeExit(pid, ownership, timeoutMs, pollMs) {
|
|
97
|
+
const deadline = Date.now() + timeoutMs;
|
|
98
|
+
const options = {
|
|
99
|
+
terminationStrategy: ownership.terminationStrategy,
|
|
100
|
+
processGroupId: ownership.processGroupId,
|
|
101
|
+
};
|
|
102
|
+
while (Date.now() < deadline) {
|
|
103
|
+
if (!isOwnedProcessTreeRunning(pid, options)) return true;
|
|
104
|
+
await sleep(pollMs);
|
|
105
|
+
}
|
|
106
|
+
return !isOwnedProcessTreeRunning(pid, options);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function terminationResult(ownership, overrides = {}) {
|
|
110
|
+
const degraded = ownership.terminationStrategy === 'process';
|
|
111
|
+
return {
|
|
112
|
+
terminated: false,
|
|
113
|
+
alreadyDead: false,
|
|
114
|
+
escalated: false,
|
|
115
|
+
signal: null,
|
|
116
|
+
scope: ownership.terminationStrategy,
|
|
117
|
+
degraded,
|
|
118
|
+
degradedReason: degraded
|
|
119
|
+
? 'Task has no process-tree ownership metadata; only the provider root PID can be terminated'
|
|
120
|
+
: null,
|
|
121
|
+
...overrides,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async function signalAndWait(pid, ownership, signal, timeoutMs, pollMs) {
|
|
126
|
+
const escalated = signal === 'SIGKILL';
|
|
127
|
+
try {
|
|
128
|
+
await signalOwnedProcessTree(pid, signal, ownership);
|
|
129
|
+
} catch (error) {
|
|
130
|
+
if (
|
|
131
|
+
!isOwnedProcessTreeRunning(pid, {
|
|
132
|
+
terminationStrategy: ownership.terminationStrategy,
|
|
133
|
+
processGroupId: ownership.processGroupId,
|
|
134
|
+
})
|
|
135
|
+
) {
|
|
136
|
+
return terminationResult(ownership, {
|
|
137
|
+
terminated: true,
|
|
138
|
+
alreadyDead: signal === 'SIGTERM',
|
|
139
|
+
escalated,
|
|
140
|
+
signal: escalated ? signal : null,
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
return terminationResult(ownership, {
|
|
144
|
+
escalated,
|
|
145
|
+
signal,
|
|
146
|
+
error: error.message,
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const terminated = await waitForOwnedProcessTreeExit(pid, ownership, timeoutMs, pollMs);
|
|
151
|
+
return terminationResult(ownership, {
|
|
152
|
+
terminated,
|
|
153
|
+
escalated,
|
|
154
|
+
signal,
|
|
155
|
+
error:
|
|
156
|
+
terminated || !escalated
|
|
157
|
+
? null
|
|
158
|
+
: `Owned ${ownership.terminationStrategy} for PID ${pid} survived ${signal}`,
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Terminate an owned provider process tree. Watchers create a dedicated process
|
|
164
|
+
* group on POSIX and persist that ownership boundary; Windows uses taskkill /T.
|
|
165
|
+
* Legacy tasks without ownership metadata fall back to root-only termination
|
|
166
|
+
* and report the degraded scope explicitly.
|
|
167
|
+
*/
|
|
168
|
+
export async function terminateProcess(pid, options = {}) {
|
|
169
|
+
let ownership;
|
|
170
|
+
try {
|
|
171
|
+
ownership = normalizeTerminationOwnership(pid, options);
|
|
172
|
+
} catch (error) {
|
|
173
|
+
return {
|
|
174
|
+
terminated: false,
|
|
175
|
+
alreadyDead: false,
|
|
176
|
+
escalated: false,
|
|
177
|
+
signal: null,
|
|
178
|
+
error: error.message,
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (!isOwnedProcessTreeRunning(pid, options)) {
|
|
183
|
+
return terminationResult(ownership, { terminated: true, alreadyDead: true });
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const graceful = await signalAndWait(
|
|
187
|
+
pid,
|
|
188
|
+
ownership,
|
|
189
|
+
'SIGTERM',
|
|
190
|
+
options.graceMs ?? 5000,
|
|
191
|
+
options.pollMs ?? 50
|
|
192
|
+
);
|
|
193
|
+
if (graceful.terminated || graceful.error) return graceful;
|
|
194
|
+
|
|
195
|
+
return signalAndWait(
|
|
196
|
+
pid,
|
|
197
|
+
ownership,
|
|
198
|
+
'SIGKILL',
|
|
199
|
+
options.hardKillWaitMs ?? 1000,
|
|
200
|
+
options.pollMs ?? 50
|
|
201
|
+
);
|
|
202
|
+
}
|