Describe the bug
TaskShellProgress.recentOutput is declared required and non-nullable, so
there is no legal way for the runtime to answer "shell task, running, nothing
emitted yet." It resolves the conflict by substituting a human-readable
placeholder into the data field:
// sdk/index.js @ 974738, identical copy in app.js @ 2324928
async getBackgroundTaskProgress(e) {
if (e.type === "shell") {
let r = this.getSessionShellContext()?.getShellTaskProgress(e.id);
return { type: "shell", recentOutput: r?.recentOutput ?? "(no output yet)", pid: r?.pid };
}
...
}
The contrast with the neighbouring field is the whole bug: pid is optional,
so when it is unknown it is simply absent — correct and unambiguous. In the
same response recentOutput cannot be absent, so absence is rendered as
content.
This is not a cosmetic default. A programmatic client cannot distinguish:
- the task has produced no output yet, from
- the task printed the characters
(no output yet).
Both arrive as the same bytes in the same required field. The API states
something false about the process, and the falsehood is undetectable at the
call site.
null does not help: the dispatcher reserves it for an unlisted id, so a
listed-but-silent task always reaches the sentinel branch —
// app.js @ 2213353
case "tasks_get_progress": {
let o = this.getBackgroundTasks().find(s => s.id === wo(n, "taskId", "id"));
return { progress: o ? await this.getBackgroundTaskProgress(o) : null }
}
Affected version
@github/copilot 1.0.80 (@github/copilot-sdk 1.0.11), win32 x64.
Current generated contract, copilot-sdk/dist/generated/rpc.d.ts:
/** @experimental */
export interface TaskShellProgress {
type: "shell";
recentOutput: string; // required — cannot express "nothing yet"
pid?: number; // optional — correctly absent when unknown
}
Steps to reproduce the behavior
Poll tasks.getProgress against a shell task that stays silent after start —
the window is easy to miss with a task that prints immediately, so force it:
-
Start an attached (sync) shell task whose command emits nothing for
~20s, then prints steadily:
node -e "setTimeout(()=>{let i=0;const t=setInterval(()=>{console.log(`line ${++i}`);if(i>60)clearInterval(t)},300)},20000)"
-
Poll tasks.getProgress({ id }) at ~250 ms from the moment the task is
listed.
-
Observe the sentinel for the whole pre-first-output window. Verbatim,
17 consecutive polls over ~4.1 s (att/exec from tasks.list):
{"ms":78782,"id":"node-task","status":"running","att":"attached","exec":"sync","recentOutput":"(no output yet)"}
{"ms":82894,"id":"node-task","status":"running","att":"attached","exec":"sync","pid":28012,"recentOutput":"(no output yet)"}
Note pid behaving correctly across those same polls — absent at 78782,
present at 82894 — while recentOutput cannot be.
-
Once the command starts printing, recentOutput carries real output
normally.
Expected behavior
recentOutput should be able to express absence, so a client can render it
honestly. Either:
- make it optional (
recentOutput?: string) and omit it — consistent with
how pid already handles the unknown case, and the preferred shape; or
- keep it required and return the empty string, reserving all non-empty
values for real process output.
Either way the invariant worth stating explicitly in the schema is: every byte
in recentOutput was written by the task. Any client-side presentation of
"nothing yet" belongs to the client.
Additional context
Why a placeholder is worse than absence here. Downstream this does not stay
cosmetic. In our host the value is non-empty, so it passes a truthiness guard
and is accumulated into the per-task buffer used to content-match a task to its
largeOutput file. No real log contains (no output yet), so the match can
never succeed and the task is permanently locked out of its own complete log —
a silent, unrecoverable failure caused entirely by a field that was supposed to
mean "no data". An empty string, or an absent field, would have been handled
correctly by code that already existed.
Relationship to #4630 — related but neither subsumes the other. #4630 asks
for largeOutputFilePath / largeOutputTotalBytes on this same interface,
because recentOutput is a lossy window. That is a fidelity gap in real
output; this is a correctness bug where the field's contents are not output at
all. They also do not overlap in time: #4630 notes the largeOutput file
appears only after a size threshold (~17s in its probe), which is precisely the
window in which this sentinel is served — so landing #4630 would leave this
unfixed, and fixing this leaves #4630's lossiness untouched. Separate fixes:
this one is a nullability change to an existing field, #4630 is a schema
addition currently blocked by "additionalProperties": false.
Scope. Observed on an attached (sync) task. The producing branch is not
conditioned on attachment, so detached tasks should behave the same, but I only
measured the attached case. The pre-first-output window is the trigger; a task
that prints immediately closes it too fast to notice, which is likely why this
has gone unreported.
Correction to an earlier reading. An attached task is not permanently
without a progress record — in the same session a different attached task
returned real recentOutput throughout. The sentinel is a startup-window state,
not an attachment-mode one.
- Discovered while polling from a GUI host embedding the SDK in-process.
- Evidence read from the shipped bundle at the offsets cited above, not inferred
from behaviour alone.
Describe the bug
TaskShellProgress.recentOutputis declared required and non-nullable, sothere is no legal way for the runtime to answer "shell task, running, nothing
emitted yet." It resolves the conflict by substituting a human-readable
placeholder into the data field:
The contrast with the neighbouring field is the whole bug:
pidis optional,so when it is unknown it is simply absent — correct and unambiguous. In the
same response
recentOutputcannot be absent, so absence is rendered ascontent.
This is not a cosmetic default. A programmatic client cannot distinguish:
(no output yet).Both arrive as the same bytes in the same required field. The API states
something false about the process, and the falsehood is undetectable at the
call site.
nulldoes not help: the dispatcher reserves it for an unlisted id, so alisted-but-silent task always reaches the sentinel branch —
Affected version
@github/copilot1.0.80 (@github/copilot-sdk1.0.11), win32 x64.Current generated contract,
copilot-sdk/dist/generated/rpc.d.ts:Steps to reproduce the behavior
Poll
tasks.getProgressagainst a shell task that stays silent after start —the window is easy to miss with a task that prints immediately, so force it:
Start an attached (
sync) shell task whose command emits nothing for~20s, then prints steadily:
Poll
tasks.getProgress({ id })at ~250 ms from the moment the task islisted.
Observe the sentinel for the whole pre-first-output window. Verbatim,
17 consecutive polls over ~4.1 s (
att/execfromtasks.list):{"ms":78782,"id":"node-task","status":"running","att":"attached","exec":"sync","recentOutput":"(no output yet)"} {"ms":82894,"id":"node-task","status":"running","att":"attached","exec":"sync","pid":28012,"recentOutput":"(no output yet)"}Note
pidbehaving correctly across those same polls — absent at 78782,present at 82894 — while
recentOutputcannot be.Once the command starts printing,
recentOutputcarries real outputnormally.
Expected behavior
recentOutputshould be able to express absence, so a client can render ithonestly. Either:
recentOutput?: string) and omit it — consistent withhow
pidalready handles the unknown case, and the preferred shape; orvalues for real process output.
Either way the invariant worth stating explicitly in the schema is: every byte
in
recentOutputwas written by the task. Any client-side presentation of"nothing yet" belongs to the client.
Additional context
Why a placeholder is worse than absence here. Downstream this does not stay
cosmetic. In our host the value is non-empty, so it passes a truthiness guard
and is accumulated into the per-task buffer used to content-match a task to its
largeOutputfile. No real log contains(no output yet), so the match cannever succeed and the task is permanently locked out of its own complete log —
a silent, unrecoverable failure caused entirely by a field that was supposed to
mean "no data". An empty string, or an absent field, would have been handled
correctly by code that already existed.
Relationship to #4630 — related but neither subsumes the other. #4630 asks
for
largeOutputFilePath/largeOutputTotalByteson this same interface,because
recentOutputis a lossy window. That is a fidelity gap in realoutput; this is a correctness bug where the field's contents are not output at
all. They also do not overlap in time: #4630 notes the
largeOutputfileappears only after a size threshold (~17s in its probe), which is precisely the
window in which this sentinel is served — so landing #4630 would leave this
unfixed, and fixing this leaves #4630's lossiness untouched. Separate fixes:
this one is a nullability change to an existing field, #4630 is a schema
addition currently blocked by
"additionalProperties": false.Scope. Observed on an attached (
sync) task. The producing branch is notconditioned on attachment, so detached tasks should behave the same, but I only
measured the attached case. The pre-first-output window is the trigger; a task
that prints immediately closes it too fast to notice, which is likely why this
has gone unreported.
Correction to an earlier reading. An attached task is not permanently
without a progress record — in the same session a different attached task
returned real
recentOutputthroughout. The sentinel is a startup-window state,not an attachment-mode one.
from behaviour alone.