makdoong2-team 1.3.0
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/LICENSE +21 -0
- package/README.md +193 -0
- package/agents/makdoong2-analyzer.md +135 -0
- package/agents/makdoong2-engineer.md +165 -0
- package/agents/makdoong2-planner.md +267 -0
- package/agents/makdoong2-publisher.md +481 -0
- package/agents/makdoong2-team-leader.md +249 -0
- package/agents/makdoong2-verifier.md +353 -0
- package/assets/makdoong2-team.default.json +46 -0
- package/assets/makdoong2-team.schema.json +357 -0
- package/bin/cli.js +404 -0
- package/dist/agent-stage-config.d.ts +15 -0
- package/dist/agent-stage-config.js +119 -0
- package/dist/config.d.ts +79 -0
- package/dist/config.js +96 -0
- package/dist/logger.d.ts +14 -0
- package/dist/logger.js +131 -0
- package/dist/mcp-secret-injector.d.ts +56 -0
- package/dist/mcp-secret-injector.js +89 -0
- package/dist/model-chain-cli.d.ts +1 -0
- package/dist/model-chain-cli.js +21 -0
- package/dist/model-fallback-policy.d.ts +69 -0
- package/dist/model-fallback-policy.js +211 -0
- package/dist/opencode-plugin.d.ts +8 -0
- package/dist/opencode-plugin.js +2457 -0
- package/dist/poll-sub-session.d.ts +139 -0
- package/dist/poll-sub-session.js +494 -0
- package/dist/redact-secrets.d.ts +3 -0
- package/dist/redact-secrets.js +68 -0
- package/dist/session-index.d.ts +11 -0
- package/dist/session-index.js +71 -0
- package/dist/skill-mcp-registry.d.ts +59 -0
- package/dist/skill-mcp-registry.js +178 -0
- package/dist/stall-escalation.d.ts +1 -0
- package/dist/stall-escalation.js +22 -0
- package/dist/tmux-monitor.d.ts +193 -0
- package/dist/tmux-monitor.js +694 -0
- package/dist/verdict-hash.d.ts +1 -0
- package/dist/verdict-hash.js +62 -0
- package/gates/stage-analysis-verify.sh +84 -0
- package/gates/stage2-requirements-verify.sh +13 -0
- package/gates/stage3-scope-verify.sh +45 -0
- package/gates/stage4-dev-post-verify.sh +64 -0
- package/gates/stage4-dev-verify.sh +36 -0
- package/gates/stage5-coverage-verify.sh +36 -0
- package/gates/stage5-test-verify.sh +24 -0
- package/gates/stage6-commit-verify.sh +41 -0
- package/gates/stage6-post-commit-verify.sh +131 -0
- package/gates/stage7-post-pr-verify.sh +53 -0
- package/gates/stage7-pr-verify.sh +48 -0
- package/gates/stage8-post-review-verify.sh +84 -0
- package/gates/stage8-review-verify.sh +45 -0
- package/gates/verify.sh +44 -0
- package/opencode.json.example +40 -0
- package/package.json +84 -0
- package/postinstall.mjs +56 -0
- package/references/commit-convention.md +130 -0
- package/references/jira-issue-templates.md +203 -0
- package/references/pr-template.md +381 -0
- package/scripts/config.sh +46 -0
- package/scripts/coverage-record.sh +67 -0
- package/scripts/gate-policy-test.sh +152 -0
- package/scripts/install-lib.mjs +1029 -0
- package/scripts/lint-agent-prompts.sh +74 -0
- package/scripts/log-event.sh +44 -0
- package/scripts/model-policy.mjs +183 -0
- package/scripts/publish-if-changed.sh +207 -0
- package/scripts/release.sh +276 -0
- package/scripts/rollback-commits.sh +35 -0
- package/scripts/smoke-test.mjs +194 -0
- package/scripts/state.sh +192 -0
- package/scripts/test-postinstall.mjs +141 -0
- package/scripts/with-fallback.sh +56 -0
- package/scripts/wt-sync-ignored.sh +193 -0
- package/skills/_lib/load-secret.sh +149 -0
- package/skills/bamboo-ci/SKILL.md +81 -0
- package/skills/bamboo-ci/run-bamboo.sh +23 -0
- package/skills/bitbucket-research/SKILL.md +87 -0
- package/skills/bitbucket-research/run-repos.sh +23 -0
- package/skills/confluence-research/SKILL.md +75 -0
- package/skills/confluence-research/run-docs.sh +23 -0
- package/skills/github-oss-research/SKILL.md +59 -0
- package/skills/jira-research/SKILL.md +75 -0
- package/skills/jira-research/run-works.sh +23 -0
- package/src/hooks/guard-bash.sh +67 -0
- package/src/hooks/session-start.sh +96 -0
- package/src/hooks/sync-state.sh +47 -0
- package/stages/01-jira.md +43 -0
- package/stages/01-planning.md +229 -0
- package/stages/02-requirements.md +298 -0
- package/stages/03-scope.md +81 -0
- package/stages/04-analysis.md +281 -0
- package/stages/05-worktree-dev.md +124 -0
- package/stages/06-test.md +161 -0
- package/stages/07-commit.md +229 -0
- package/stages/08-pr.md +177 -0
- package/stages/09-review-comments.md +277 -0
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
export type MessagePartLike = {
|
|
2
|
+
type: string;
|
|
3
|
+
text?: string;
|
|
4
|
+
};
|
|
5
|
+
export type MessageInfoLike = {
|
|
6
|
+
id?: string;
|
|
7
|
+
role: string;
|
|
8
|
+
finish?: unknown;
|
|
9
|
+
};
|
|
10
|
+
export type MessageLike = {
|
|
11
|
+
info: MessageInfoLike;
|
|
12
|
+
parts: MessagePartLike[];
|
|
13
|
+
};
|
|
14
|
+
export type StatusEntryLike = {
|
|
15
|
+
type: string;
|
|
16
|
+
};
|
|
17
|
+
export type PermissionRequestLike = {
|
|
18
|
+
id: string;
|
|
19
|
+
sessionID: string;
|
|
20
|
+
permission: string;
|
|
21
|
+
patterns: string[];
|
|
22
|
+
};
|
|
23
|
+
export interface PollClientLike {
|
|
24
|
+
session: {
|
|
25
|
+
status: () => Promise<{
|
|
26
|
+
data?: Record<string, StatusEntryLike>;
|
|
27
|
+
} | null | undefined>;
|
|
28
|
+
messages: (req: {
|
|
29
|
+
path: {
|
|
30
|
+
id: string;
|
|
31
|
+
};
|
|
32
|
+
}) => Promise<{
|
|
33
|
+
data?: MessageLike[];
|
|
34
|
+
} | null | undefined>;
|
|
35
|
+
abort: (req: {
|
|
36
|
+
path: {
|
|
37
|
+
id: string;
|
|
38
|
+
};
|
|
39
|
+
}) => Promise<unknown>;
|
|
40
|
+
};
|
|
41
|
+
permission?: {
|
|
42
|
+
list: () => Promise<{
|
|
43
|
+
data?: PermissionRequestLike[];
|
|
44
|
+
} | null | undefined>;
|
|
45
|
+
reply: (req: {
|
|
46
|
+
path: {
|
|
47
|
+
requestID: string;
|
|
48
|
+
};
|
|
49
|
+
body: {
|
|
50
|
+
reply: string;
|
|
51
|
+
};
|
|
52
|
+
}) => Promise<unknown>;
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
export type PollOutcome = {
|
|
56
|
+
kind: "text";
|
|
57
|
+
text: string;
|
|
58
|
+
polls: number;
|
|
59
|
+
elapsedMs: number;
|
|
60
|
+
} | {
|
|
61
|
+
kind: "empty";
|
|
62
|
+
reason: string;
|
|
63
|
+
polls: number;
|
|
64
|
+
elapsedMs: number;
|
|
65
|
+
} | {
|
|
66
|
+
kind: "timeout";
|
|
67
|
+
polls: number;
|
|
68
|
+
elapsedMs: number;
|
|
69
|
+
transientFailures: number;
|
|
70
|
+
} | {
|
|
71
|
+
kind: "aborted";
|
|
72
|
+
reason: string;
|
|
73
|
+
polls: number;
|
|
74
|
+
elapsedMs: number;
|
|
75
|
+
} | {
|
|
76
|
+
kind: "permission_stall";
|
|
77
|
+
polls: number;
|
|
78
|
+
elapsedMs: number;
|
|
79
|
+
stalledMs: number;
|
|
80
|
+
permissionID?: string;
|
|
81
|
+
permissionType?: string;
|
|
82
|
+
} | {
|
|
83
|
+
kind: "session_gone";
|
|
84
|
+
polls: number;
|
|
85
|
+
elapsedMs: number;
|
|
86
|
+
reason?: string;
|
|
87
|
+
};
|
|
88
|
+
export interface PollOptions {
|
|
89
|
+
timeoutMs?: number;
|
|
90
|
+
pollIntervalMs?: number;
|
|
91
|
+
pollSafetyMargin?: number;
|
|
92
|
+
permissionCheckIntervalPolls?: number;
|
|
93
|
+
allowedWorktree?: string;
|
|
94
|
+
configuredAllowPatterns?: string[];
|
|
95
|
+
now?: () => number;
|
|
96
|
+
sleep?: (ms: number) => Promise<void>;
|
|
97
|
+
logger?: {
|
|
98
|
+
debug?: (msg: string) => void;
|
|
99
|
+
error?: (msg: string) => void;
|
|
100
|
+
};
|
|
101
|
+
toolCallStallThresholdMs?: number;
|
|
102
|
+
messageStallThresholdMs?: number;
|
|
103
|
+
statusAbsentGraceMs?: number;
|
|
104
|
+
isRecentlyActive?: () => boolean;
|
|
105
|
+
contentStableCompletionMs?: number;
|
|
106
|
+
preambleOnlyTextThreshold?: number;
|
|
107
|
+
nudgeAtFraction?: number;
|
|
108
|
+
onNudge?: (sessionId: string, elapsedMs: number) => Promise<void>;
|
|
109
|
+
}
|
|
110
|
+
export declare function isWithinWorktreeScope(patterns: string[], worktree: string): boolean;
|
|
111
|
+
export declare function isMatchedByConfiguredRules(patterns: string[], allowedGlobs: string[]): boolean;
|
|
112
|
+
/**
|
|
113
|
+
* Poll a sub-session until it becomes idle (or times out) and extract the
|
|
114
|
+
* last assistant response as plain text.
|
|
115
|
+
*
|
|
116
|
+
* The returned {@link PollOutcome} discriminates four terminal states so that
|
|
117
|
+
* callers can set the "success" flag correctly:
|
|
118
|
+
*
|
|
119
|
+
* - `text` : sub-agent produced final text output. success=true.
|
|
120
|
+
* - `empty` : sub-agent completed but produced no text parts. success=false.
|
|
121
|
+
* - `timeout` : deadline exceeded. abort() invoked. success=false.
|
|
122
|
+
* - `aborted` : safety limit hit (maxPolls). abort() invoked. success=false.
|
|
123
|
+
*
|
|
124
|
+
* Callers previously conflated "text" and "empty" via a fallback string
|
|
125
|
+
* `"(session complete, no text output)"`, which made the orchestrator report
|
|
126
|
+
* success even when the sub-agent produced nothing. This function forces the
|
|
127
|
+
* distinction so `dispatch_stage` can return `ok:false` on empty output.
|
|
128
|
+
*/
|
|
129
|
+
export declare function pollSubSession(client: PollClientLike, sessionId: string, options?: PollOptions): Promise<PollOutcome>;
|
|
130
|
+
/**
|
|
131
|
+
* Convert a {@link PollOutcome} into a display string plus a boolean success
|
|
132
|
+
* flag. Used by callers that need a single string for downstream serialization
|
|
133
|
+
* (e.g., embedding in a JSON response) but must also know whether the
|
|
134
|
+
* sub-agent actually produced work.
|
|
135
|
+
*/
|
|
136
|
+
export declare function pollOutcomeToLegacy(outcome: PollOutcome): {
|
|
137
|
+
text: string;
|
|
138
|
+
success: boolean;
|
|
139
|
+
};
|
|
@@ -0,0 +1,494 @@
|
|
|
1
|
+
// poll-sub-session.ts — pure, testable polling primitive for sub-sessions.
|
|
2
|
+
//
|
|
3
|
+
// Extracted from opencode-plugin.ts so that end-conditions (idle, finish,
|
|
4
|
+
// timeout, transient failures, empty parts) can be unit-tested with a mock
|
|
5
|
+
// client. The plugin passes its real `client` at call sites; tests inject a
|
|
6
|
+
// scripted mock via the same shape.
|
|
7
|
+
//
|
|
8
|
+
// Design invariants (see AGENTS.md and Oracle diagnosis for rationale):
|
|
9
|
+
// INV-1: `!status` alone must never mean "idle". A missing status entry can
|
|
10
|
+
// equally mean "session not registered yet" or "session cleaned up".
|
|
11
|
+
// Idle is confirmed only by `status.type === "idle"` AFTER we have
|
|
12
|
+
// seen the session appear in the status map at least once.
|
|
13
|
+
// INV-2: transient `session.status()` / `session.messages()` failures must
|
|
14
|
+
// be retried, not silently treated as "no messages". They advance
|
|
15
|
+
// `transientFailures` counter and are surfaced when polling ends.
|
|
16
|
+
// INV-3: ordering is derived from message array index, not from lexicographic
|
|
17
|
+
// ID comparison. Message IDs may be UUIDs (non-monotonic strings).
|
|
18
|
+
// INV-4: the result must distinguish (a) sub-agent produced text, (b) idle
|
|
19
|
+
// but no text (still an error case for callers), (c) transient
|
|
20
|
+
// failure timeout, (d) hard timeout. Callers set `success` per this
|
|
21
|
+
// discrimination.
|
|
22
|
+
const TOOL_CALL_PART_TYPES = new Set(["tool_call", "tool-call", "tool_use"]);
|
|
23
|
+
// path.posix.dirname is used directly so the module works in both Node and test
|
|
24
|
+
// environments without importing 'path'. The separator is always '/' because
|
|
25
|
+
// opencode worktree paths are POSIX.
|
|
26
|
+
function posixDirname(p) {
|
|
27
|
+
const trimmed = p.replace(/\/+$/, "");
|
|
28
|
+
const idx = trimmed.lastIndexOf("/");
|
|
29
|
+
return idx <= 0 ? "/" : trimmed.slice(0, idx);
|
|
30
|
+
}
|
|
31
|
+
// Returns true when every permission pattern resides within the allowed scope,
|
|
32
|
+
// defined as the parent directory of the worktree (siblings are the main repo
|
|
33
|
+
// and other worktrees — all legitimate access targets for engineers).
|
|
34
|
+
// Trailing glob suffixes (/* , /*/ , /**/ , /**) are stripped before prefix check.
|
|
35
|
+
export function isWithinWorktreeScope(patterns, worktree) {
|
|
36
|
+
if (!worktree || patterns.length === 0)
|
|
37
|
+
return false;
|
|
38
|
+
const scope = posixDirname(worktree);
|
|
39
|
+
return patterns.every(pat => {
|
|
40
|
+
const base = pat.replace(/\/?(\*+\/?)+$/, "");
|
|
41
|
+
return base === scope || base.startsWith(scope + "/");
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
// Returns true when every permission pattern is covered by at least one
|
|
45
|
+
// configured allow glob from opencode.json's external_directory section.
|
|
46
|
+
//
|
|
47
|
+
// Deliberately uses prefix comparison rather than path.matchesGlob because
|
|
48
|
+
// matchesGlob's "**" does NOT traverse dot-prefixed directories (.nvm, .config).
|
|
49
|
+
// opencode.json patterns that contain dot-directories must therefore be written
|
|
50
|
+
// as absolute-path prefixes (e.g. "/root/.nvm/**") rather than middle-wildcard
|
|
51
|
+
// globs ("**/@local/**") to work reliably in both opencode and this function.
|
|
52
|
+
export function isMatchedByConfiguredRules(patterns, allowedGlobs) {
|
|
53
|
+
if (patterns.length === 0 || allowedGlobs.length === 0)
|
|
54
|
+
return false;
|
|
55
|
+
return patterns.every(pat => {
|
|
56
|
+
const base = pat.replace(/\/?(\*+\/?)+$/, "");
|
|
57
|
+
return allowedGlobs.some(glob => {
|
|
58
|
+
const globBase = glob.replace(/\/?(\*+\/?)+$/, "");
|
|
59
|
+
return base === globBase || base.startsWith(globBase + "/");
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Poll a sub-session until it becomes idle (or times out) and extract the
|
|
65
|
+
* last assistant response as plain text.
|
|
66
|
+
*
|
|
67
|
+
* The returned {@link PollOutcome} discriminates four terminal states so that
|
|
68
|
+
* callers can set the "success" flag correctly:
|
|
69
|
+
*
|
|
70
|
+
* - `text` : sub-agent produced final text output. success=true.
|
|
71
|
+
* - `empty` : sub-agent completed but produced no text parts. success=false.
|
|
72
|
+
* - `timeout` : deadline exceeded. abort() invoked. success=false.
|
|
73
|
+
* - `aborted` : safety limit hit (maxPolls). abort() invoked. success=false.
|
|
74
|
+
*
|
|
75
|
+
* Callers previously conflated "text" and "empty" via a fallback string
|
|
76
|
+
* `"(session complete, no text output)"`, which made the orchestrator report
|
|
77
|
+
* success even when the sub-agent produced nothing. This function forces the
|
|
78
|
+
* distinction so `dispatch_stage` can return `ok:false` on empty output.
|
|
79
|
+
*/
|
|
80
|
+
export async function pollSubSession(client, sessionId, options = {}) {
|
|
81
|
+
const timeoutMs = options.timeoutMs ?? 1_800_000;
|
|
82
|
+
const pollIntervalMs = options.pollIntervalMs ?? 2_000;
|
|
83
|
+
const pollSafetyMargin = options.pollSafetyMargin ?? 10;
|
|
84
|
+
const now = options.now ?? Date.now;
|
|
85
|
+
const sleep = options.sleep ?? ((ms) => new Promise(r => setTimeout(r, ms)));
|
|
86
|
+
const dbg = options.logger?.debug;
|
|
87
|
+
const err = options.logger?.error ?? ((msg) => console.error(msg));
|
|
88
|
+
const startTime = now();
|
|
89
|
+
const deadline = startTime + timeoutMs;
|
|
90
|
+
const expectedPolls = Math.ceil(timeoutMs / pollIntervalMs);
|
|
91
|
+
const maxPolls = expectedPolls + pollSafetyMargin;
|
|
92
|
+
const toolCallStallThresholdMs = options.toolCallStallThresholdMs ?? 60_000;
|
|
93
|
+
const messageStallThresholdMs = options.messageStallThresholdMs;
|
|
94
|
+
const statusAbsentGraceMs = options.statusAbsentGraceMs ?? 300_000;
|
|
95
|
+
const contentStableCompletionMs = options.contentStableCompletionMs;
|
|
96
|
+
const preambleOnlyTextThreshold = options.preambleOnlyTextThreshold;
|
|
97
|
+
const isRecentlyActive = options.isRecentlyActive;
|
|
98
|
+
const permissionCheckIntervalPolls = options.permissionCheckIntervalPolls ?? 5;
|
|
99
|
+
let pollCount = 0;
|
|
100
|
+
let transientFailures = 0;
|
|
101
|
+
let sessionEverAppeared = false;
|
|
102
|
+
let hasProducedAssistantMessage = false;
|
|
103
|
+
let lastProgressAt = startTime;
|
|
104
|
+
let lastSeenMessageCount = -1;
|
|
105
|
+
let lastAssistantSig = "";
|
|
106
|
+
let nudged = false;
|
|
107
|
+
let firstGoneObservedAt = null;
|
|
108
|
+
dbg?.(`[pollSubSession] START session=${sessionId} timeoutMs=${timeoutMs}`);
|
|
109
|
+
while (now() < deadline) {
|
|
110
|
+
await sleep(pollIntervalMs);
|
|
111
|
+
pollCount++;
|
|
112
|
+
if (pollCount > maxPolls) {
|
|
113
|
+
err(`[pollSubSession] ABORT session=${sessionId} reason=max_polls_exceeded count=${pollCount}/${maxPolls}`);
|
|
114
|
+
await client.session.abort({ path: { id: sessionId } }).catch(() => undefined);
|
|
115
|
+
return {
|
|
116
|
+
kind: "aborted",
|
|
117
|
+
reason: `exceeded max polling attempts ${pollCount}/${maxPolls}`,
|
|
118
|
+
polls: pollCount,
|
|
119
|
+
elapsedMs: now() - startTime,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
let statusFailed = false;
|
|
123
|
+
const statusResult = await client.session.status().catch((e) => {
|
|
124
|
+
err(`[pollSubSession] session.status() FAILED session=${sessionId} poll=${pollCount} error=${e}`);
|
|
125
|
+
statusFailed = true;
|
|
126
|
+
return null;
|
|
127
|
+
});
|
|
128
|
+
const allStatuses = statusResult?.data;
|
|
129
|
+
const status = allStatuses?.[sessionId];
|
|
130
|
+
if (status)
|
|
131
|
+
sessionEverAppeared = true;
|
|
132
|
+
if (statusFailed)
|
|
133
|
+
transientFailures++;
|
|
134
|
+
let messagesFailed = false;
|
|
135
|
+
const msgResult = await client.session
|
|
136
|
+
.messages({ path: { id: sessionId } })
|
|
137
|
+
.catch((e) => {
|
|
138
|
+
err(`[pollSubSession] session.messages() FAILED session=${sessionId} poll=${pollCount} error=${e}`);
|
|
139
|
+
messagesFailed = true;
|
|
140
|
+
return null;
|
|
141
|
+
});
|
|
142
|
+
const messages = msgResult?.data ?? [];
|
|
143
|
+
if (messagesFailed)
|
|
144
|
+
transientFailures++;
|
|
145
|
+
// INV-2: transient failures do not advance completion detection. Just retry.
|
|
146
|
+
if (statusFailed || messagesFailed) {
|
|
147
|
+
dbg?.(`[pollSubSession] TRANSIENT session=${sessionId} poll=${pollCount} statusFailed=${statusFailed} messagesFailed=${messagesFailed}`);
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
const lengthChanged = messages.length !== lastSeenMessageCount;
|
|
151
|
+
if (lengthChanged)
|
|
152
|
+
lastSeenMessageCount = messages.length;
|
|
153
|
+
// sessionEverAppeared stays strict (status-map only). A second liveness
|
|
154
|
+
// signal, sessionAliveByMessages, unlocks defense paths for worktree-CWD
|
|
155
|
+
// sessions where the server's status map is CWD-filtered and never
|
|
156
|
+
// contains the sessionId. Keeping the two signals separate lets
|
|
157
|
+
// session_gone (status_absent) fire immediately for the strict case
|
|
158
|
+
// (status was present, then disappeared — real disappearance) while
|
|
159
|
+
// requiring an additional hasProducedAssistantMessage gate for the loose
|
|
160
|
+
// case (worktree — session was never in status map, so we can't
|
|
161
|
+
// distinguish bootstrap from real gone without prior output evidence).
|
|
162
|
+
const sessionAliveByMessages = messages.length > 0;
|
|
163
|
+
// INV-3: ordering is derived from array index, not lexicographic ID compare.
|
|
164
|
+
let lastAssistantIdx = -1;
|
|
165
|
+
let lastUserIdx = -1;
|
|
166
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
167
|
+
if (lastAssistantIdx < 0 && messages[i].info.role === "assistant")
|
|
168
|
+
lastAssistantIdx = i;
|
|
169
|
+
if (lastUserIdx < 0 && messages[i].info.role === "user")
|
|
170
|
+
lastUserIdx = i;
|
|
171
|
+
if (lastAssistantIdx >= 0 && lastUserIdx >= 0)
|
|
172
|
+
break;
|
|
173
|
+
}
|
|
174
|
+
const lastAssistant = lastAssistantIdx >= 0 ? messages[lastAssistantIdx] : undefined;
|
|
175
|
+
if (lastAssistant)
|
|
176
|
+
hasProducedAssistantMessage = true;
|
|
177
|
+
// Content signature captures streaming text appends and new part additions
|
|
178
|
+
// that leave messages.length unchanged. Without this, an assistant that
|
|
179
|
+
// streams a 5-minute reasoning block into a single message would appear
|
|
180
|
+
// frozen to length-only comparison and could trigger status_absent gone
|
|
181
|
+
// (Oracle-flagged risk #1). Combining length-change with signature-change
|
|
182
|
+
// gives the correct "any progress?" signal for both single-message
|
|
183
|
+
// streaming and normal multi-message flows.
|
|
184
|
+
const lastAsstTextLen = lastAssistant?.parts
|
|
185
|
+
? lastAssistant.parts.reduce((s, p) => s + (typeof p.text === "string" ? p.text.length : 0), 0)
|
|
186
|
+
: 0;
|
|
187
|
+
const lastAsstPartsLen = lastAssistant?.parts?.length ?? 0;
|
|
188
|
+
const currentAsstSig = lastAssistant
|
|
189
|
+
? `${lastAssistant.info?.id ?? ""}:${lastAsstPartsLen}:${lastAsstTextLen}`
|
|
190
|
+
: "";
|
|
191
|
+
const contentChanged = currentAsstSig !== "" && currentAsstSig !== lastAssistantSig;
|
|
192
|
+
const messagesChanged = lengthChanged || contentChanged;
|
|
193
|
+
if (messagesChanged) {
|
|
194
|
+
lastAssistantSig = currentAsstSig;
|
|
195
|
+
lastProgressAt = now();
|
|
196
|
+
}
|
|
197
|
+
const properOrdering = lastAssistant && lastUserIdx >= 0
|
|
198
|
+
? lastAssistantIdx > lastUserIdx
|
|
199
|
+
: Boolean(lastAssistant);
|
|
200
|
+
const hasFinish = lastAssistant?.info?.finish != null;
|
|
201
|
+
const hasPendingToolCall = !!lastAssistant?.parts?.some(p => TOOL_CALL_PART_TYPES.has(p.type));
|
|
202
|
+
const stalledMs = now() - lastProgressAt;
|
|
203
|
+
// session_gone (status_absent) has two admission paths with different
|
|
204
|
+
// strictness:
|
|
205
|
+
// strict — status was previously present (opencode confirmed the
|
|
206
|
+
// session, then it vanished from the map).
|
|
207
|
+
// loose — worktree-CWD case, status never present. Requires prior
|
|
208
|
+
// assistant output to guard against slow-first-token bootstrap
|
|
209
|
+
// false-positive; the correct path for bootstrap hang is
|
|
210
|
+
// message_stall (opt-in threshold), not gone-detection.
|
|
211
|
+
//
|
|
212
|
+
// Both paths ALSO require `!isRecentlyActive()` — an external liveness
|
|
213
|
+
// callback (typically wired to the opencode plugin's tool.execute.before
|
|
214
|
+
// hook) that observes tool activity even when server-side status pushes
|
|
215
|
+
// stall. Recent tool activity resets both the admission and the
|
|
216
|
+
// grace-period timer, so a session that fires tools every few seconds
|
|
217
|
+
// will never be classified gone regardless of how long the status map
|
|
218
|
+
// lags behind.
|
|
219
|
+
//
|
|
220
|
+
// Once admitted, the outcome is not fired until `statusAbsentGraceMs`
|
|
221
|
+
// has elapsed since the FIRST admission observation (not since start of
|
|
222
|
+
// poll). Historical logic used a 3-poll counter (~8 s) which produced
|
|
223
|
+
// heavy false positives on slow-first-token models. The grace-period
|
|
224
|
+
// model keeps timing decoupled from poll frequency and yields intuitive
|
|
225
|
+
// wall-clock semantics.
|
|
226
|
+
const activeSignal = isRecentlyActive?.() === true;
|
|
227
|
+
const goneAdmitted = !activeSignal &&
|
|
228
|
+
!hasPendingToolCall &&
|
|
229
|
+
!messagesChanged &&
|
|
230
|
+
!status &&
|
|
231
|
+
(sessionEverAppeared || (sessionAliveByMessages && hasProducedAssistantMessage));
|
|
232
|
+
if (goneAdmitted) {
|
|
233
|
+
if (firstGoneObservedAt === null) {
|
|
234
|
+
firstGoneObservedAt = now();
|
|
235
|
+
dbg?.(`[pollSubSession] GONE_ADMIT session=${sessionId} poll=${pollCount} ` +
|
|
236
|
+
`sig="${currentAsstSig}" pending_tool=${hasPendingToolCall} ` +
|
|
237
|
+
`session_ever_appeared=${sessionEverAppeared} alive_by_msgs=${sessionAliveByMessages} ` +
|
|
238
|
+
`messages=${messages.length} grace_ms=${statusAbsentGraceMs} ` +
|
|
239
|
+
`— gone admission started; will fire in ${statusAbsentGraceMs}ms if condition persists`);
|
|
240
|
+
}
|
|
241
|
+
const goneElapsedMs = now() - firstGoneObservedAt;
|
|
242
|
+
if (goneElapsedMs >= statusAbsentGraceMs) {
|
|
243
|
+
err(`[pollSubSession] SESSION_GONE session=${sessionId} polls=${pollCount} ` +
|
|
244
|
+
`gone_elapsed_ms=${goneElapsedMs} grace_ms=${statusAbsentGraceMs} ` +
|
|
245
|
+
`sig="${currentAsstSig}" messages=${messages.length} ` +
|
|
246
|
+
`— status disappeared after appearing, no new messages, no recent tool activity`);
|
|
247
|
+
return {
|
|
248
|
+
kind: "session_gone",
|
|
249
|
+
polls: pollCount,
|
|
250
|
+
elapsedMs: now() - startTime,
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
else {
|
|
255
|
+
if (firstGoneObservedAt !== null) {
|
|
256
|
+
dbg?.(`[pollSubSession] GONE_ADMIT_RESET session=${sessionId} poll=${pollCount} ` +
|
|
257
|
+
`active_signal=${activeSignal} pending_tool=${hasPendingToolCall} ` +
|
|
258
|
+
`messages_changed=${messagesChanged} status=${status?.type ?? "absent"} ` +
|
|
259
|
+
`— gone admission cleared before grace elapsed`);
|
|
260
|
+
}
|
|
261
|
+
firstGoneObservedAt = null;
|
|
262
|
+
}
|
|
263
|
+
if (client.permission && pollCount % permissionCheckIntervalPolls === 0) {
|
|
264
|
+
const permResult = await client.permission.list().catch(() => null);
|
|
265
|
+
const pending = (permResult?.data ?? []).filter(p => p.sessionID === sessionId);
|
|
266
|
+
for (const p of pending) {
|
|
267
|
+
const withinScope = p.permission === "external_directory" && ((!!options.allowedWorktree && isWithinWorktreeScope(p.patterns, options.allowedWorktree)) ||
|
|
268
|
+
isMatchedByConfiguredRules(p.patterns, options.configuredAllowPatterns ?? []));
|
|
269
|
+
if (withinScope) {
|
|
270
|
+
dbg?.(`[pollSubSession] PERMISSION_ALLOW session=${sessionId} polls=${pollCount}` +
|
|
271
|
+
` permissionID=${p.id} type=${p.permission} patterns=${JSON.stringify(p.patterns)}` +
|
|
272
|
+
` scope=dirname(${options.allowedWorktree})`);
|
|
273
|
+
await client.permission
|
|
274
|
+
.reply({ path: { requestID: p.id }, body: { reply: "once" } })
|
|
275
|
+
.catch(() => undefined);
|
|
276
|
+
}
|
|
277
|
+
else {
|
|
278
|
+
err(`[pollSubSession] PERMISSION_STALL session=${sessionId} polls=${pollCount}` +
|
|
279
|
+
` permissionID=${p.id} type=${p.permission} patterns=${JSON.stringify(p.patterns)}` +
|
|
280
|
+
` — auto-rejecting (outside worktree scope or non-external_directory)`);
|
|
281
|
+
await client.permission
|
|
282
|
+
.reply({ path: { requestID: p.id }, body: { reply: "reject" } })
|
|
283
|
+
.catch(() => undefined);
|
|
284
|
+
await client.session.abort({ path: { id: sessionId } }).catch(() => undefined);
|
|
285
|
+
return {
|
|
286
|
+
kind: "permission_stall",
|
|
287
|
+
polls: pollCount,
|
|
288
|
+
elapsedMs: now() - startTime,
|
|
289
|
+
stalledMs,
|
|
290
|
+
permissionID: p.id,
|
|
291
|
+
permissionType: p.permission,
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
if (hasPendingToolCall && stalledMs >= toolCallStallThresholdMs) {
|
|
297
|
+
err(`[pollSubSession] PERMISSION_STALL session=${sessionId} polls=${pollCount} stalledMs=${stalledMs}`);
|
|
298
|
+
await client.session.abort({ path: { id: sessionId } }).catch(() => undefined);
|
|
299
|
+
return {
|
|
300
|
+
kind: "permission_stall",
|
|
301
|
+
polls: pollCount,
|
|
302
|
+
elapsedMs: now() - startTime,
|
|
303
|
+
stalledMs,
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
// Message stall: sub-agent registered (sessionEverAppeared) and appears
|
|
307
|
+
// busy, but has made no progress within the configured threshold.
|
|
308
|
+
// Progress = new message OR content-signature change on the last
|
|
309
|
+
// assistant message (see the `messagesChanged` computation above).
|
|
310
|
+
// Catches two hang modes:
|
|
311
|
+
// (a) bootstrap hang — no assistant message at all (upstream silent,
|
|
312
|
+
// quota exhausted, local server crash). `lastProgressAt` stays at
|
|
313
|
+
// startTime so `stalledFromProgress` grows monotonically.
|
|
314
|
+
// (b) mid-stream LLM inference hang — assistant appeared but then
|
|
315
|
+
// froze mid-response. `lastProgressAt` last advanced when the
|
|
316
|
+
// final signature change was seen; from that point onward the
|
|
317
|
+
// LLM is silent while status stays busy.
|
|
318
|
+
// Heavy tool executions (docker/gradle builds spanning minutes) are
|
|
319
|
+
// exempted via `!hasPendingToolCall` — pending tool call means the
|
|
320
|
+
// sub-agent is waiting on our runtime, not on the LLM API. When the
|
|
321
|
+
// caller opts in via messageStallThresholdMs (dispatch_stage passes
|
|
322
|
+
// escalating per-attempt values), the check returns session_gone with
|
|
323
|
+
// reason="message_stall" so the retry loop kicks in with a fresh
|
|
324
|
+
// sub-session. abort() is safe here (session is real, just stalled on
|
|
325
|
+
// the LLM API), so unlike status-absent session_gone we do NOT need
|
|
326
|
+
// skipSessionOps handling downstream.
|
|
327
|
+
//
|
|
328
|
+
// Busy-signal detection: `status?.type === "busy"` is the primary
|
|
329
|
+
// signal when the server's status map contains the sessionId. For
|
|
330
|
+
// worktree-CWD sessions the map is CWD-filtered and never contains the
|
|
331
|
+
// sessionId, so status is always absent — sessionAliveByMessages (loose
|
|
332
|
+
// liveness) plus a busy indicator derived from messages.length become
|
|
333
|
+
// equivalent.
|
|
334
|
+
const elapsedFromStart = now() - startTime;
|
|
335
|
+
const busyIndicated = status?.type === "busy" || (!status && messages.length > 0);
|
|
336
|
+
const stalledFromProgress = now() - lastProgressAt;
|
|
337
|
+
if (messageStallThresholdMs !== undefined &&
|
|
338
|
+
(sessionEverAppeared || sessionAliveByMessages) &&
|
|
339
|
+
busyIndicated &&
|
|
340
|
+
!hasPendingToolCall &&
|
|
341
|
+
stalledFromProgress >= messageStallThresholdMs) {
|
|
342
|
+
const hangMode = lastAssistant ? "mid_stream" : "bootstrap";
|
|
343
|
+
err(`[pollSubSession] MESSAGE_STALL session=${sessionId} polls=${pollCount} ` +
|
|
344
|
+
`mode=${hangMode} stalled_from_progress_ms=${stalledFromProgress} ` +
|
|
345
|
+
`threshold_ms=${messageStallThresholdMs} elapsed_ms=${elapsedFromStart} ` +
|
|
346
|
+
`messages=${messages.length} — LLM API hang suspected (no progress while status=busy)`);
|
|
347
|
+
await client.session.abort({ path: { id: sessionId } }).catch(() => undefined);
|
|
348
|
+
return {
|
|
349
|
+
kind: "session_gone",
|
|
350
|
+
polls: pollCount,
|
|
351
|
+
elapsedMs: elapsedFromStart,
|
|
352
|
+
reason: "message_stall",
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
const finishComplete = hasFinish && !hasPendingToolCall && properOrdering;
|
|
356
|
+
// INV-1: `!status` alone is not idle. Require `status.type === "idle"` AND
|
|
357
|
+
// that we saw the session in the status map at least once (or that the
|
|
358
|
+
// messages list already contains an assistant response).
|
|
359
|
+
const statusIdle = !!status && status.type === "idle";
|
|
360
|
+
// Content-stable third-arm completion (see contentStableCompletionMs docs
|
|
361
|
+
// on PollOptions). Fires only when the sub-agent has produced at least
|
|
362
|
+
// one assistant message, has no pending tool_call, and has not advanced
|
|
363
|
+
// its content for the configured threshold. Prevents SESSION_GONE /
|
|
364
|
+
// MESSAGE_STALL false-positives on providers/sessions where neither
|
|
365
|
+
// statusIdle nor finishComplete ever fires (worktree-CWD, no finish field).
|
|
366
|
+
const contentStable = contentStableCompletionMs !== undefined &&
|
|
367
|
+
hasProducedAssistantMessage &&
|
|
368
|
+
!hasPendingToolCall &&
|
|
369
|
+
stalledFromProgress >= contentStableCompletionMs;
|
|
370
|
+
const looksComplete = statusIdle || finishComplete || contentStable;
|
|
371
|
+
dbg?.(`[pollSubSession] POLL session=${sessionId} poll=${pollCount} status=${status?.type ?? "absent"} ` +
|
|
372
|
+
`messages=${messages.length} finishComplete=${finishComplete} statusIdle=${statusIdle} ` +
|
|
373
|
+
`contentStable=${contentStable} sessionEverAppeared=${sessionEverAppeared}`);
|
|
374
|
+
if (!nudged && options.nudgeAtFraction != null && options.onNudge) {
|
|
375
|
+
const elapsedMs = now() - startTime;
|
|
376
|
+
if (elapsedMs >= options.nudgeAtFraction * timeoutMs) {
|
|
377
|
+
// Guard: only NUDGE if the session appears alive (has messages or present in status).
|
|
378
|
+
// Prevents NotFoundError on orphaned/gone sessions where tmux pane persists but
|
|
379
|
+
// the OpenCode session was already deleted (bug #2: PROJ-40406 orphan scenario).
|
|
380
|
+
const sessionLooksAlive = sessionAliveByMessages || status !== undefined;
|
|
381
|
+
if (sessionLooksAlive) {
|
|
382
|
+
nudged = true;
|
|
383
|
+
dbg?.(`[pollSubSession] NUDGE session=${sessionId} elapsed=${elapsedMs}ms fraction=${options.nudgeAtFraction}`);
|
|
384
|
+
await options.onNudge(sessionId, elapsedMs).catch(() => undefined);
|
|
385
|
+
}
|
|
386
|
+
else {
|
|
387
|
+
dbg?.(`[pollSubSession] NUDGE_SKIP session=${sessionId} elapsed=${elapsedMs}ms ` +
|
|
388
|
+
`reason=session_looks_dead (no_messages=${messages.length === 0} no_status=${!status})`);
|
|
389
|
+
nudged = true; // Mark as nudged to prevent retry on next poll
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
if (!looksComplete)
|
|
394
|
+
continue;
|
|
395
|
+
// Completion signal fired. Require that we have observed the session at
|
|
396
|
+
// least once via status OR that at least one assistant message exists.
|
|
397
|
+
// This blocks the "!status on first poll before session registered" false
|
|
398
|
+
// positive that plagued v0.10.1 rollback.
|
|
399
|
+
if (!sessionEverAppeared && messages.length === 0) {
|
|
400
|
+
dbg?.(`[pollSubSession] IGNORE completion (session never appeared, no messages) session=${sessionId} poll=${pollCount}`);
|
|
401
|
+
continue;
|
|
402
|
+
}
|
|
403
|
+
if (!lastAssistant) {
|
|
404
|
+
return {
|
|
405
|
+
kind: "empty",
|
|
406
|
+
reason: "no assistant message",
|
|
407
|
+
polls: pollCount,
|
|
408
|
+
elapsedMs: now() - startTime,
|
|
409
|
+
};
|
|
410
|
+
}
|
|
411
|
+
const text = (lastAssistant.parts ?? [])
|
|
412
|
+
.filter(p => p.type === "text" && typeof p.text === "string" && p.text.length > 0)
|
|
413
|
+
.map(p => p.text)
|
|
414
|
+
.join("\n");
|
|
415
|
+
if (text.length > 0) {
|
|
416
|
+
if (preambleOnlyTextThreshold !== undefined &&
|
|
417
|
+
preambleOnlyTextThreshold > 0 &&
|
|
418
|
+
text.trim().length < preambleOnlyTextThreshold &&
|
|
419
|
+
!hasPendingToolCall) {
|
|
420
|
+
dbg?.(`[pollSubSession] preamble-only detected session=${sessionId} ` +
|
|
421
|
+
`textLen=${text.trim().length} threshold=${preambleOnlyTextThreshold} — ` +
|
|
422
|
+
`reclassifying text outcome as empty`);
|
|
423
|
+
return {
|
|
424
|
+
kind: "empty",
|
|
425
|
+
reason: "preamble_only",
|
|
426
|
+
polls: pollCount,
|
|
427
|
+
elapsedMs: now() - startTime,
|
|
428
|
+
};
|
|
429
|
+
}
|
|
430
|
+
return { kind: "text", text, polls: pollCount, elapsedMs: now() - startTime };
|
|
431
|
+
}
|
|
432
|
+
return {
|
|
433
|
+
kind: "empty",
|
|
434
|
+
reason: "assistant message has no text parts",
|
|
435
|
+
polls: pollCount,
|
|
436
|
+
elapsedMs: now() - startTime,
|
|
437
|
+
};
|
|
438
|
+
}
|
|
439
|
+
err(`[pollSubSession] TIMEOUT session=${sessionId} polls=${pollCount} transientFailures=${transientFailures}`);
|
|
440
|
+
await client.session.abort({ path: { id: sessionId } }).catch(() => undefined);
|
|
441
|
+
return {
|
|
442
|
+
kind: "timeout",
|
|
443
|
+
polls: pollCount,
|
|
444
|
+
elapsedMs: now() - startTime,
|
|
445
|
+
transientFailures,
|
|
446
|
+
};
|
|
447
|
+
}
|
|
448
|
+
/**
|
|
449
|
+
* Convert a {@link PollOutcome} into a display string plus a boolean success
|
|
450
|
+
* flag. Used by callers that need a single string for downstream serialization
|
|
451
|
+
* (e.g., embedding in a JSON response) but must also know whether the
|
|
452
|
+
* sub-agent actually produced work.
|
|
453
|
+
*/
|
|
454
|
+
export function pollOutcomeToLegacy(outcome) {
|
|
455
|
+
switch (outcome.kind) {
|
|
456
|
+
case "text":
|
|
457
|
+
return { text: outcome.text, success: true };
|
|
458
|
+
case "empty":
|
|
459
|
+
return {
|
|
460
|
+
text: `(session complete, no text output — ${outcome.reason})`,
|
|
461
|
+
success: false,
|
|
462
|
+
};
|
|
463
|
+
case "timeout":
|
|
464
|
+
return {
|
|
465
|
+
text: `(timeout: sub-agent did not complete within the allotted time; ` +
|
|
466
|
+
`polls=${outcome.polls} transientFailures=${outcome.transientFailures})`,
|
|
467
|
+
success: false,
|
|
468
|
+
};
|
|
469
|
+
case "aborted":
|
|
470
|
+
return { text: `(aborted: ${outcome.reason})`, success: false };
|
|
471
|
+
case "permission_stall":
|
|
472
|
+
return {
|
|
473
|
+
text: outcome.permissionType
|
|
474
|
+
? `(permission_stall: sub-agent blocked on ${outcome.permissionType} permission (id=${outcome.permissionID}) — auto-rejected and aborted)`
|
|
475
|
+
: `(permission_stall: sub-agent tool call stalled for ${outcome.stalledMs}ms — likely waiting for external_directory permission approval that cannot be answered in subagent context)`,
|
|
476
|
+
success: false,
|
|
477
|
+
};
|
|
478
|
+
case "session_gone":
|
|
479
|
+
return {
|
|
480
|
+
text: outcome.reason === "message_stall"
|
|
481
|
+
? `(session_gone[message_stall]: sub-session busy for ${outcome.elapsedMs}ms without producing any assistant message ` +
|
|
482
|
+
`(polls=${outcome.polls}) — LLM API hang suspected. Caller should redispatch a new session.)`
|
|
483
|
+
: `(session_gone: sub-session disappeared from status map after appearing, no new messages, ` +
|
|
484
|
+
`and no recent tool activity for at least the configured grace period ` +
|
|
485
|
+
`(polls=${outcome.polls}, elapsed=${outcome.elapsedMs}ms). ` +
|
|
486
|
+
`Caller should redispatch a new session instead of retrying this one.)`,
|
|
487
|
+
success: false,
|
|
488
|
+
};
|
|
489
|
+
default: {
|
|
490
|
+
const _exhaustive = outcome;
|
|
491
|
+
throw new Error(`Unhandled outcome kind: ${_exhaustive.kind}`);
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
}
|